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
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user