Files
arboretum/packages/server/src/core/pty-manager.ts
Johan LEROY 4e3b1f5604 fix(sessions): reprendre/forker une session managée morte par UUID (résout le 404)
Le bouton « Reprendre » envoie l'UUID Arboretum, mais resume/fork ne
résolvaient l'id que via la découverte (indexée par claudeSessionId, qui
exclut justement les sessions managées) → 404 systématique après fermeture.
Ajoute PtyManager.resumeTargetById (cwd d'origine + claudeSessionId + groupe
lus en DB par UUID) et un résolveur unifié dans resume ET fork : session
managée morte par UUID, sinon session claude externe découverte par
claudeSessionId. Garde-fous anti-resume d'une session vivante conservés.
2026-06-22 14:41:06 +02:00

514 lines
20 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { existsSync, statSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js';
import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
const KILL_GRACE_MS = 5000;
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
const NOTIFY_DEBOUNCE_MS = 1500;
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
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;
mode: 'interactive' | 'observer';
controlling: boolean;
sentBytes: number;
ackedBytes: number;
lagging: boolean;
sendOutput: (payload: Buffer) => void;
sendResync: (payload: Buffer) => void;
onDetached: (reason: 'session_exit' | 'replaced') => void;
onControlChanged: (controlling: boolean) => void;
}
interface ManagedSession {
id: string;
cwd: string;
command: 'claude' | 'bash';
title: string | null;
createdAt: string;
proc: pty.IPty;
ring: RingBuffer;
clients: Set<ClientBinding>;
paused: boolean;
exited: { exitCode: number | null; signal: number | null } | null;
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). */
prevActivity: SessionActivity | null;
/** timer de notif push débouncée (annulé si la session quitte `waiting` avant l'échéance). */
notifyTimer: NodeJS.Timeout | null;
}
export interface PtyManagerEvents {
session_update: [SessionSummary];
session_exit: [{ sessionId: string; exitCode: number | null; signal: number | null }];
}
export class PtyManager extends EventEmitter<PtyManagerEvents> {
private readonly live = new Map<string, ManagedSession>();
constructor(
private readonly db: Db,
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
private readonly push: PushService | null = null,
) {
super();
}
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 } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
});
const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, {
name: 'xterm-256color',
cols: 120,
rows: 32,
cwd,
env: spec.env as { [key: string]: string },
});
const session: ManagedSession = {
id,
cwd,
command,
title: null,
createdAt: new Date().toISOString(),
proc,
ring: new RingBuffer(RING_CAPACITY),
clients: new Set(),
paused: false,
exited: null,
killTimer: null,
claudeSessionId: null,
addedDirs,
groupId: opts.groupId ?? null,
tracker: null,
prevActivity: null,
notifyTimer: null,
};
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
if (command === 'claude') {
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
if (session.exited) return;
const summary = this.summarize(session);
this.maybeNotify(session, summary.activity ?? null);
this.emit('session_update', summary);
});
}
this.live.set(id, session);
this.db
.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));
if (command === 'claude') this.captureClaudeSessionId(session);
const summary = this.summarize(session);
this.emit('session_update', summary);
return summary;
}
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady — S1). */
private captureClaudeSessionId(s: ManagedSession): void {
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
const tick = (): void => {
if (s.exited || s.claudeSessionId) return;
const entry = findByPid(this.sessionsDir, s.proc.pid);
if (entry?.claudeSessionId) {
s.claudeSessionId = entry.claudeSessionId;
this.db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run(entry.claudeSessionId, s.id);
this.emit('session_update', this.summarize(s));
return;
}
if (Date.now() < deadline) setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
};
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 };
}
/**
* Cible de reprise d'une session managée MORTE (P2/P6), résolue par UUID Arboretum : son cwd
* d'origine, son claudeSessionId et son contexte de groupe, lus en DB. null si l'id ne correspond
* pas à une session managée morte, reprenable (claude + claudeSessionId connu).
* Complète `DiscoveryService.getDiscovered`, qui ne couvre QUE les sessions claude EXTERNES
* (une managée connue est justement exclue de la découverte).
*/
resumeTargetById(id: string): { cwd: string; claudeSessionId: string; addedDirs: string[]; groupId: string | null } | null {
if (this.live.has(id)) return null; // vivante : pas de resume direct (fork via le même chemin)
const row = this.db
.prepare(
"SELECT cwd, claude_session_id, added_dirs, group_id FROM sessions WHERE id = ? AND ended_at IS NOT NULL AND claude_session_id IS NOT NULL AND command = 'claude'",
)
.get(id) as { cwd: string; claude_session_id: string; added_dirs: string | null; group_id: string | null } | undefined;
if (!row) return null;
return { cwd: row.cwd, claudeSessionId: row.claude_session_id, 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()) {
if (s.claudeSessionId === claudeSessionId) return this.summarize(s);
}
return null;
}
/**
* Ensemble des claudeSessionId déjà connus d'Arboretum (sessions managées vivantes + historique DB).
* Sert au DiscoveryService à exclure les doublons (une managée ne doit pas réapparaître en découverte).
*/
knownClaudeSessionIds(): Set<string> {
const rows = this.db
.prepare('SELECT claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL')
.all() as Array<{ claude_session_id: string }>;
const set = new Set(rows.map((r) => r.claude_session_id));
for (const s of this.live.values()) if (s.claudeSessionId) set.add(s.claudeSessionId);
return set;
}
list(): SessionSummary[] {
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, 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) => {
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];
}
get(id: string): SessionSummary | null {
const s = this.live.get(id);
return s ? this.summarize(s) : null;
}
kill(id: string): boolean {
const s = this.live.get(id);
if (!s || s.exited) return false;
try {
process.kill(s.proc.pid, 'SIGTERM');
} catch {
return false;
}
s.killTimer ??= setTimeout(() => {
if (!s.exited) {
try {
process.kill(s.proc.pid, 'SIGKILL');
} catch {
/* déjà mort */
}
}
}, KILL_GRACE_MS);
return true;
}
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
shutdown(): void {
for (const id of this.live.keys()) this.kill(id);
}
// ---- attach / detach / io ----
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
const s = this.live.get(sessionId);
if (!s) return { ok: false, code: 'NOT_FOUND' };
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
const hasController = [...s.clients].some((c) => c.controlling);
binding.controlling = binding.mode === 'interactive' && !hasController;
s.clients.add(binding);
if (binding.controlling) {
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
binding.ackedBytes = 0;
return { ok: true, controlling: binding.controlling };
}
detach(sessionId: string, binding: ClientBinding): void {
const s = this.live.get(sessionId);
if (!s) return;
s.clients.delete(binding);
if (binding.controlling) {
const next = [...s.clients].find((c) => c.mode === 'interactive');
if (next) {
next.controlling = true;
next.onControlChanged(true);
}
}
this.updateFlowControl(s);
}
write(sessionId: string, binding: ClientBinding, data: string): 'ok' | 'not_controlling' | 'gone' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
// Outil mono-utilisateur : tous les interactifs peuvent écrire ; les observers jamais.
if (binding.mode !== 'interactive') return 'not_controlling';
s.proc.write(data);
return 'ok';
}
/**
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
* - 'deny' : refus universel (Esc).
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
*/
answer(
sessionId: string,
binding: ClientBinding,
action: 'select' | 'confirm' | 'deny',
optionN?: number,
): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
if (binding.mode !== 'interactive') return 'not_controlling';
const act = s.tracker?.snapshot();
if (action === 'select') {
// L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
s.proc.write(`${optionN}\r`);
return 'ok';
}
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
}
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
const s = this.live.get(sessionId);
if (!s) return;
binding.ackedBytes = Math.max(binding.ackedBytes, bytes);
if (binding.lagging && binding.sentBytes - binding.ackedBytes < FLOW.LOW_WATERMARK) {
binding.lagging = false;
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
binding.ackedBytes = 0;
}
this.updateFlowControl(s);
}
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
*/
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
const prev = s.prevActivity;
s.prevActivity = next;
if (!this.push) return;
if (next === 'waiting' && prev !== 'waiting') {
if (s.notifyTimer) clearTimeout(s.notifyTimer);
s.notifyTimer = setTimeout(() => {
s.notifyTimer = null;
const act = s.tracker?.snapshot();
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
void this.push?.notify({
sessionId: s.id,
title: basename(s.cwd) || s.cwd,
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
kind: act.dialog?.kind ?? null,
url: `/sessions/${s.id}`,
});
}, NOTIFY_DEBOUNCE_MS);
s.notifyTimer.unref();
} else if (next !== 'waiting' && s.notifyTimer) {
clearTimeout(s.notifyTimer);
s.notifyTimer = null;
}
}
private handleOutput(s: ManagedSession, chunk: Buffer): void {
s.ring.write(chunk);
s.tracker?.feed(chunk);
for (const c of s.clients) {
if (c.lagging) continue;
c.sendOutput(chunk);
c.sentBytes += chunk.length;
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
}
this.updateFlowControl(s);
}
/**
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
*/
private updateFlowControl(s: ManagedSession): void {
if (s.exited) return;
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
if (interactive.length === 0) {
if (s.paused) {
s.proc.resume();
s.paused = false;
}
return;
}
const outstandings = interactive.map((c) => c.sentBytes - c.ackedBytes);
const min = Math.min(...outstandings);
if (!s.paused && outstandings.every((o) => o > FLOW.HIGH_WATERMARK)) {
s.proc.pause();
s.paused = true;
} else if (s.paused && min < FLOW.LOW_WATERMARK) {
s.proc.resume();
s.paused = false;
}
}
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
s.exited = { exitCode, signal };
s.tracker?.dispose();
s.tracker = null;
if (s.killTimer) clearTimeout(s.killTimer);
if (s.notifyTimer) clearTimeout(s.notifyTimer);
const endedAt = new Date().toISOString();
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
for (const c of s.clients) c.onDetached('session_exit');
s.clients.clear();
this.live.delete(s.id);
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited', endedAt });
}
private summarize(s: ManagedSession): SessionSummary {
const act = s.tracker?.snapshot();
return {
id: s.id,
cwd: s.cwd,
command: s.command,
title: s.title,
status: s.exited ? 'exited' : 'running',
live: !s.exited,
createdAt: s.createdAt,
endedAt: null,
exitCode: s.exited?.exitCode ?? null,
clients: s.clients.size,
source: 'managed',
claudeSessionId: s.claudeSessionId,
pid: s.exited ? null : s.proc.pid,
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
attachable: !s.exited,
// statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
registryStatus: act?.activity ?? null,
activity: act?.activity ?? null,
waitingFor: act?.waitingFor ?? null,
dialog: act?.dialog ?? null,
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
groupId: s.groupId,
};
}
}