feat(groups): session Claude unique par groupe via --add-dir (P6)
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s

Un groupe lance désormais UNE seule session Claude couvrant tous ses
repos/worktrees (--add-dir) au lieu d'une session par repo, pour
travailler en simultané dans une conversation partagée. Le transport WS,
le flow control et l'attach sont inchangés (toujours 1 session, 1 channel).

- shared: SessionSummary.addedDirs/groupId (champs additifs optionnels) ;
  CreateGroupSessionRequest / GroupSessionResponse.
- server: --add-dir dans claude-launcher ; spawn({addDirs,groupId}) +
  persistance (migration #8 : sessions.added_dirs/group_id) ;
  POST /api/v1/groups/:id/session (résolution des dirs cote serveur) ;
  resume re-relie les dirs ; deleteGroup nullifie group_id.
- web: GroupSessionModal (modes feature / checkouts principaux) ;
  createGroupFeature/createGroupSession ; badge « groupe · N depots » ;
  sessionsInGroup dedupliquee.
- site: textes EN/FR « une seule session Claude » + diagramme hub.
- tests: claude-launcher, pty-manager (groupe), group-manager ;
  acceptation P5 etendue (session de groupe multi-repo). 272 tests verts.
This commit is contained in:
2026-06-22 10:06:23 +02:00
parent ca6700d6ce
commit c6ea9f7930
23 changed files with 619 additions and 226 deletions

View File

@@ -10,6 +10,8 @@ export interface SpawnOptions {
command: 'claude' | 'bash';
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
resume?: { claudeSessionId: string; fork?: boolean };
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
addDirs?: string[];
}
let cachedClaudeBin: string | null = null;
@@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
args.push('--resume', opts.resume.claudeSessionId);
if (opts.resume.fork) args.push('--fork-session');
}
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
return { file: resolveClaudeBin(), args, env };
}

View File

@@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter<GroupManagerEvents> {
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
if (res.changes === 0) return false;
// Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement.
this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id);
this.emit('group_removed', id);
return true;
}

View File

@@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500;
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
function parseAddedDirs(raw: string | null): string[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
export interface ClientBinding {
channel: number;
@@ -48,6 +59,10 @@ interface ManagedSession {
killTimer: NodeJS.Timeout | null;
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
claudeSessionId: string | null;
/** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */
addedDirs: string[];
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
groupId: string | null;
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
tracker: SessionActivityTracker | null;
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
@@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
super();
}
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
spawn(opts: {
cwd: string;
command?: 'claude' | 'bash';
resume?: { claudeSessionId: string; fork?: boolean };
/** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */
addDirs?: string[];
/** groupe propriétaire (session de groupe, P6). */
groupId?: string;
}): SessionSummary {
const cwd = opts.cwd;
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
}
// Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd).
const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd);
for (const dir of addedDirs) {
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
}
}
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
const spec = buildSpawnSpec({
command,
...(opts.resume ? { resume: opts.resume } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
});
const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, {
name: 'xterm-256color',
@@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
exited: null,
killTimer: null,
claudeSessionId: null,
addedDirs,
groupId: opts.groupId ?? null,
tracker: null,
prevActivity: null,
notifyTimer: null,
@@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
this.live.set(id, session);
this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(
id,
cwd,
command,
session.createdAt,
opts.resume?.claudeSessionId ?? null,
addedDirs.length ? JSON.stringify(addedDirs) : null,
session.groupId,
);
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
@@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
}
/**
* Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id`
* persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires.
*/
groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null {
const row = this.db
.prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1')
.get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined;
if (!row) return null;
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
}
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
for (const s of this.live.values()) {
@@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys());
const rows = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id))
.map((r) => ({
id: r.id,
cwd: r.cwd,
command: r.command,
title: r.title,
status: 'exited',
live: false,
createdAt: r.created_at,
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
source: 'managed',
claudeSessionId: r.claude_session_id,
pid: null,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
resumable: r.command === 'claude' && r.claude_session_id != null,
attachable: false,
registryStatus: null,
}));
.map((r) => {
const addedDirs = parseAddedDirs(r.added_dirs);
return {
id: r.id,
cwd: r.cwd,
command: r.command,
title: r.title,
status: 'exited' as const,
live: false,
createdAt: r.created_at,
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
source: 'managed' as const,
claudeSessionId: r.claude_session_id,
pid: null,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
resumable: r.command === 'claude' && r.claude_session_id != null,
attachable: false,
registryStatus: null,
...(addedDirs.length ? { addedDirs } : {}),
groupId: r.group_id,
};
});
return [...liveSummaries, ...historical];
}
@@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
activity: act?.activity ?? null,
waitingFor: act?.waitingFor ?? null,
dialog: act?.dialog ?? null,
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
groupId: s.groupId,
};
}
}