codekin 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +1 -0
  2. package/dist/assets/{index-COGLICp9.js → index-BFkKlY3O.js} +46 -42
  3. package/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/server/dist/approval-manager.js +1 -2
  6. package/server/dist/approval-manager.js.map +1 -1
  7. package/server/dist/claude-process.d.ts +9 -0
  8. package/server/dist/claude-process.js +35 -3
  9. package/server/dist/claude-process.js.map +1 -1
  10. package/server/dist/config.js +2 -1
  11. package/server/dist/config.js.map +1 -1
  12. package/server/dist/native-permissions.js +1 -1
  13. package/server/dist/native-permissions.js.map +1 -1
  14. package/server/dist/orchestrator-children.js +22 -6
  15. package/server/dist/orchestrator-children.js.map +1 -1
  16. package/server/dist/orchestrator-reports.d.ts +0 -4
  17. package/server/dist/orchestrator-reports.js +6 -12
  18. package/server/dist/orchestrator-reports.js.map +1 -1
  19. package/server/dist/prompt-router.d.ts +95 -0
  20. package/server/dist/prompt-router.js +577 -0
  21. package/server/dist/prompt-router.js.map +1 -0
  22. package/server/dist/session-lifecycle.d.ts +73 -0
  23. package/server/dist/session-lifecycle.js +387 -0
  24. package/server/dist/session-lifecycle.js.map +1 -0
  25. package/server/dist/session-manager.d.ts +33 -60
  26. package/server/dist/session-manager.js +234 -801
  27. package/server/dist/session-manager.js.map +1 -1
  28. package/server/dist/session-persistence.js +21 -3
  29. package/server/dist/session-persistence.js.map +1 -1
  30. package/server/dist/tsconfig.tsbuildinfo +1 -1
  31. package/server/dist/types.d.ts +4 -0
  32. package/server/dist/ws-message-handler.js +2 -1
  33. package/server/dist/ws-message-handler.js.map +1 -1
  34. package/server/dist/ws-server.js +6 -0
  35. package/server/dist/ws-server.js.map +1 -1
@@ -15,25 +15,25 @@
15
15
  * - SessionNaming: AI-powered session name generation with retry logic
16
16
  * - SessionPersistence: disk I/O for session state
17
17
  * - DiffManager: stateless git-diff operations
18
- * - evaluateRestart: pure restart-decision logic
18
+ * - SessionLifecycle: Claude process start/stop/restart and event wiring
19
+ * - evaluateRestart: pure restart-decision logic (used by SessionLifecycle)
19
20
  */
20
21
  import { randomUUID } from 'crypto';
21
22
  import { execFile } from 'child_process';
22
- import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs';
23
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'fs';
23
24
  import { homedir } from 'os';
24
25
  import path from 'path';
25
26
  import { promisify } from 'util';
26
- import { ClaudeProcess } from './claude-process.js';
27
27
  import { PlanManager } from './plan-manager.js';
28
28
  import { SessionArchive } from './session-archive.js';
29
29
  import { cleanupWorkspace } from './webhook-workspace.js';
30
30
  import { PORT } from './config.js';
31
31
  import { ApprovalManager } from './approval-manager.js';
32
+ import { PromptRouter } from './prompt-router.js';
33
+ import { SessionLifecycle } from './session-lifecycle.js';
32
34
  import { SessionNaming } from './session-naming.js';
33
35
  import { SessionPersistence } from './session-persistence.js';
34
- import { deriveSessionToken } from './crypto-utils.js';
35
36
  import { cleanGitEnv, DiffManager } from './diff-manager.js';
36
- import { evaluateRestart } from './session-restart-scheduler.js';
37
37
  const execFileAsync = promisify(execFile);
38
38
  /** Max messages retained in a session's output history buffer. */
39
39
  const MAX_HISTORY = 2000;
@@ -89,12 +89,51 @@ export class SessionManager {
89
89
  sessionPersistence;
90
90
  /** Delegated diff operations (git diff, discard changes). */
91
91
  diffManager;
92
+ /** Delegated prompt routing and tool approval logic. */
93
+ promptRouter;
94
+ /** Delegated Claude process lifecycle (start, stop, restart, event wiring). */
95
+ sessionLifecycle;
92
96
  /** Interval handle for the idle session reaper. */
93
97
  _idleReaperInterval = null;
94
98
  constructor() {
95
99
  this.archive = new SessionArchive();
96
100
  this._approvalManager = new ApprovalManager();
97
101
  this.diffManager = new DiffManager();
102
+ this.promptRouter = new PromptRouter({
103
+ getSession: (id) => this.sessions.get(id),
104
+ allSessions: () => this.sessions.values(),
105
+ broadcast: (session, msg) => this.broadcast(session, msg),
106
+ addToHistory: (session, msg) => this.addToHistory(session, msg),
107
+ globalBroadcast: (msg) => this._globalBroadcast?.(msg),
108
+ approvalManager: this._approvalManager,
109
+ promptListeners: this._promptListeners,
110
+ });
111
+ // Use a local ref so the getter closures capture `this` (the SessionManager instance)
112
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
113
+ const self = this;
114
+ this.sessionLifecycle = new SessionLifecycle({
115
+ getSession: (id) => this.sessions.get(id),
116
+ hasSession: (id) => this.sessions.has(id),
117
+ broadcast: (session, msg) => this.broadcast(session, msg),
118
+ addToHistory: (session, msg) => this.addToHistory(session, msg),
119
+ broadcastAndHistory: (session, msg) => this.broadcastAndHistory(session, msg),
120
+ persistToDisk: () => this.persistToDisk(),
121
+ get globalBroadcast() { return self._globalBroadcast; },
122
+ get authToken() { return self._authToken; },
123
+ get serverPort() { return self._serverPort; },
124
+ approvalManager: this._approvalManager,
125
+ promptRouter: this.promptRouter,
126
+ exitListeners: this._exitListeners,
127
+ onSystemInit: (cp, session, model) => this.onSystemInit(cp, session, model),
128
+ onTextEvent: (session, sessionId, text) => this.onTextEvent(session, sessionId, text),
129
+ onThinkingEvent: (session, summary) => this.onThinkingEvent(session, summary),
130
+ onToolOutputEvent: (session, content, isError) => this.onToolOutputEvent(session, content, isError),
131
+ onImageEvent: (session, base64, mediaType) => this.onImageEvent(session, base64, mediaType),
132
+ onToolActiveEvent: (session, toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput),
133
+ onToolDoneEvent: (session, toolName, summary) => this.onToolDoneEvent(session, toolName, summary),
134
+ handleClaudeResult: (session, sessionId, result, isError) => this.handleClaudeResult(session, sessionId, result, isError),
135
+ buildSessionContext: (session) => this.buildSessionContext(session),
136
+ });
98
137
  this.sessionPersistence = new SessionPersistence(this.sessions);
99
138
  this.sessionNaming = new SessionNaming({
100
139
  getSession: (id) => this.sessions.get(id),
@@ -132,6 +171,10 @@ export class SessionManager {
132
171
  if (idleMs > IDLE_SESSION_TIMEOUT_MS) {
133
172
  console.log(`[idle-reaper] stopping idle session=${session.id} name="${session.name}" idle=${Math.round(idleMs / 60_000)}min`);
134
173
  session._stoppedByUser = true; // prevent auto-restart
174
+ if (session._restartTimer) {
175
+ clearTimeout(session._restartTimer);
176
+ session._restartTimer = undefined;
177
+ }
135
178
  session.claudeProcess.removeAllListeners();
136
179
  session.claudeProcess.stop();
137
180
  session.claudeProcess = null;
@@ -235,8 +278,16 @@ export class SessionManager {
235
278
  * Create a git worktree for a session. Creates a new branch and worktree
236
279
  * as a sibling directory of the project root.
237
280
  * Returns the worktree path on success, or null on failure.
281
+ *
282
+ * @param targetBranch — use this as the worktree branch name instead of
283
+ * the default `wt/{shortId}`. The orchestrator uses this to create the
284
+ * worktree directly on the desired feature branch so Claude doesn't
285
+ * need to create a second branch.
286
+ * @param baseBranch — create the worktree branch from this ref (e.g.
287
+ * 'main'). Defaults to auto-detecting the default branch. Prevents
288
+ * worktrees from accidentally branching off a random HEAD.
238
289
  */
239
- async createWorktree(sessionId, workingDir) {
290
+ async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
240
291
  const session = this.sessions.get(sessionId);
241
292
  if (!session)
242
293
  return null;
@@ -253,23 +304,72 @@ export class SessionManager {
253
304
  console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
254
305
  return null;
255
306
  }
256
- const prefix = this.getWorktreeBranchPrefix();
257
307
  const shortId = sessionId.slice(0, 8);
258
- const branchName = `${prefix}${shortId}`;
308
+ const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
259
309
  const projectName = path.basename(repoRoot);
260
310
  const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
311
+ // Auto-detect the default branch if baseBranch not specified.
312
+ // Tries origin/HEAD, then falls back to common names.
313
+ let resolvedBase = baseBranch;
314
+ if (!resolvedBase) {
315
+ resolvedBase = await this.detectDefaultBranch(repoRoot, env) ?? undefined;
316
+ }
317
+ // Determine if this is an ephemeral branch (wt/ prefix, generated by us)
318
+ // vs a caller-supplied branch name (e.g. fix/feature-xyz from orchestrator).
319
+ // Caller-supplied branches must NEVER be force-deleted — they may contain
320
+ // unique commits from a previous session or manual work.
321
+ const isEphemeralBranch = !targetBranch;
322
+ // Check if the target branch already exists as a local branch
323
+ let branchExists = false;
324
+ try {
325
+ await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { cwd: repoRoot, env, timeout: 3000 });
326
+ branchExists = true;
327
+ }
328
+ catch {
329
+ // Branch doesn't exist — will be created
330
+ }
261
331
  // Clean up stale state from previous failed attempts:
262
332
  // 1. Prune orphaned worktree entries (directory gone but git still tracks it)
263
333
  await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
264
334
  .catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
265
- // 2. Remove existing worktree directory if leftover from a partial failure
335
+ // 2. Remove existing worktree directory if leftover from a partial failure.
336
+ // If git doesn't recognise it as a worktree, force-remove the directory
337
+ // so that `git worktree add` below doesn't fail with "already exists".
266
338
  await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
267
- .catch((e) => console.debug(`[worktree] remove prior worktree (expected if fresh):`, e instanceof Error ? e.message : e));
268
- // 3. Delete the branch if it exists (leftover from a failed worktree add)
269
- await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
270
- .catch((e) => console.debug(`[worktree] branch cleanup (expected if fresh):`, e instanceof Error ? e.message : e));
271
- // Create the worktree with a new branch
272
- await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath], {
339
+ .catch((e) => {
340
+ console.debug(`[worktree] remove prior worktree (expected if fresh):`, e instanceof Error ? e.message : e);
341
+ // Git doesn't know about it nuke the stale directory if it still exists
342
+ if (existsSync(worktreePath)) {
343
+ try {
344
+ rmSync(worktreePath, { recursive: true, force: true });
345
+ console.log(`[worktree] Force-removed stale directory: ${worktreePath}`);
346
+ }
347
+ catch (rmErr) {
348
+ console.warn(`[worktree] Failed to force-remove stale directory ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
349
+ }
350
+ }
351
+ });
352
+ // 3. Only delete ephemeral branches (wt/*) during cleanup — never caller-supplied ones
353
+ if (isEphemeralBranch && branchExists) {
354
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
355
+ .catch((e) => console.debug(`[worktree] ephemeral branch cleanup:`, e instanceof Error ? e.message : e));
356
+ branchExists = false;
357
+ }
358
+ // Create the worktree:
359
+ // - Existing branch: check it out in the worktree (no -b)
360
+ // - New branch: create with -b, branching from the resolved base
361
+ let worktreeArgs;
362
+ if (branchExists) {
363
+ worktreeArgs = ['worktree', 'add', worktreePath, branchName];
364
+ console.log(`[worktree] Using existing branch ${branchName}`);
365
+ }
366
+ else {
367
+ worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath];
368
+ if (resolvedBase)
369
+ worktreeArgs.push(resolvedBase);
370
+ console.log(`[worktree] Creating new branch ${branchName}${resolvedBase ? ` from ${resolvedBase}` : ''}`);
371
+ }
372
+ await execFileAsync('git', worktreeArgs, {
273
373
  cwd: repoRoot,
274
374
  env,
275
375
  timeout: 15000,
@@ -363,8 +463,11 @@ export class SessionManager {
363
463
  /**
364
464
  * Clean up a git worktree and its branch. Runs asynchronously and logs errors
365
465
  * but never throws — session deletion must not be blocked by cleanup failures.
466
+ * Retries once on failure after a short delay.
366
467
  */
367
- cleanupWorktree(worktreePath, repoDir) {
468
+ cleanupWorktree(worktreePath, repoDir, attempt = 1) {
469
+ const MAX_CLEANUP_ATTEMPTS = 2;
470
+ const RETRY_DELAY_MS = 3000;
368
471
  void (async () => {
369
472
  try {
370
473
  // Resolve the actual repo root (repoDir may itself be a worktree)
@@ -384,10 +487,62 @@ export class SessionManager {
384
487
  .catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
385
488
  }
386
489
  catch (err) {
387
- console.warn(`[worktree] Failed to clean up worktree ${worktreePath}:`, err instanceof Error ? err.message : err);
490
+ const errMsg = err instanceof Error ? err.message : String(err);
491
+ if (attempt < MAX_CLEANUP_ATTEMPTS) {
492
+ console.warn(`[worktree] Failed to clean up worktree ${worktreePath} (attempt ${attempt}/${MAX_CLEANUP_ATTEMPTS}): ${errMsg} — retrying in ${RETRY_DELAY_MS}ms`);
493
+ setTimeout(() => this.cleanupWorktree(worktreePath, repoDir, attempt + 1), RETRY_DELAY_MS);
494
+ }
495
+ else {
496
+ console.error(`[worktree] Failed to clean up worktree ${worktreePath} after ${MAX_CLEANUP_ATTEMPTS} attempts: ${errMsg}`);
497
+ // Last resort: force-remove the directory so it doesn't block future
498
+ // worktree creation or leave the session in a broken restart loop.
499
+ if (existsSync(worktreePath)) {
500
+ try {
501
+ rmSync(worktreePath, { recursive: true, force: true });
502
+ console.log(`[worktree] Force-removed stale worktree directory: ${worktreePath}`);
503
+ }
504
+ catch (rmErr) {
505
+ console.error(`[worktree] Failed to force-remove ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
506
+ }
507
+ }
508
+ }
388
509
  }
389
510
  })();
390
511
  }
512
+ /**
513
+ * Detect the default branch of a repository (main, master, etc.).
514
+ * Tries `git symbolic-ref refs/remotes/origin/HEAD` first, then checks
515
+ * for common branch names. Returns null if detection fails.
516
+ */
517
+ async detectDefaultBranch(repoRoot, env) {
518
+ // Try origin/HEAD (set by git clone or git remote set-head)
519
+ try {
520
+ const { stdout } = await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: repoRoot, env, timeout: 5000 });
521
+ const ref = stdout.trim(); // e.g. "refs/remotes/origin/main"
522
+ if (ref) {
523
+ const branch = ref.replace('refs/remotes/origin/', '');
524
+ console.log(`[worktree] Detected default branch from origin/HEAD: ${branch}`);
525
+ return branch;
526
+ }
527
+ }
528
+ catch {
529
+ // origin/HEAD not set — fall through to heuristics
530
+ }
531
+ // Check for common default branch names — use show-ref to verify
532
+ // these are actual local branches, not tags or other refs
533
+ for (const candidate of ['main', 'master']) {
534
+ try {
535
+ await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { cwd: repoRoot, env, timeout: 3000 });
536
+ console.log(`[worktree] Detected default branch by name: ${candidate}`);
537
+ return candidate;
538
+ }
539
+ catch {
540
+ // branch doesn't exist, try next
541
+ }
542
+ }
543
+ console.warn(`[worktree] Could not detect default branch for ${repoRoot} — worktree will branch from HEAD`);
544
+ return null;
545
+ }
391
546
  /** Get the configured worktree branch prefix (defaults to 'wt/'). */
392
547
  getWorktreeBranchPrefix() {
393
548
  return this.archive.getSetting('worktree_branch_prefix', 'wt/');
@@ -424,30 +579,7 @@ export class SessionManager {
424
579
  }
425
580
  /** Get all sessions that have pending prompts (waiting for approval or answer). */
426
581
  getPendingPrompts() {
427
- const results = [];
428
- for (const session of this.sessions.values()) {
429
- const prompts = [];
430
- for (const [reqId, pending] of session.pendingToolApprovals) {
431
- prompts.push({
432
- requestId: reqId,
433
- promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
434
- toolName: pending.toolName,
435
- toolInput: pending.toolInput,
436
- });
437
- }
438
- for (const [reqId, pending] of session.pendingControlRequests) {
439
- prompts.push({
440
- requestId: reqId,
441
- promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
442
- toolName: pending.toolName,
443
- toolInput: pending.toolInput,
444
- });
445
- }
446
- if (prompts.length > 0) {
447
- results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
448
- }
449
- }
450
- return results;
582
+ return this.promptRouter.getPendingPrompts();
451
583
  }
452
584
  /** Clear the isProcessing flag for a session and broadcast the update. */
453
585
  clearProcessingFlag(sessionId) {
@@ -578,15 +710,29 @@ export class SessionManager {
578
710
  clearTimeout(session._namingTimer);
579
711
  if (session._leaveGraceTimer)
580
712
  clearTimeout(session._leaveGraceTimer);
581
- // Kill claude process if running
582
- if (session.claudeProcess) {
583
- session.claudeProcess.stop();
584
- session.claudeProcess = null;
585
- }
713
+ if (session._restartTimer) {
714
+ clearTimeout(session._restartTimer);
715
+ session._restartTimer = undefined;
716
+ }
717
+ // Kill claude process if running — remove listeners first to prevent the
718
+ // exit handler from triggering an auto-restart on a deleted session.
719
+ // Wait for the process to fully exit before worktree cleanup so we don't
720
+ // run `git worktree remove` while Claude is still performing git operations.
721
+ const exitPromise = session.claudeProcess
722
+ ? (() => {
723
+ const cp = session.claudeProcess;
724
+ cp.removeAllListeners();
725
+ cp.stop();
726
+ session.claudeProcess = null;
727
+ return cp.waitForExit();
728
+ })()
729
+ : Promise.resolve();
586
730
  this.archiveSessionIfWorthSaving(session);
587
- // Clean up git worktree if this session used one
731
+ // Clean up git worktree if this session used one — deferred until process exits
588
732
  if (session.worktreePath) {
589
- this.cleanupWorktree(session.worktreePath, session.groupDir ?? session.workingDir);
733
+ const wtPath = session.worktreePath;
734
+ const repoDir = session.groupDir ?? session.workingDir;
735
+ void exitPromise.then(() => this.cleanupWorktree(wtPath, repoDir));
590
736
  }
591
737
  // Clean up webhook workspace directory if applicable
592
738
  if (session.source === 'webhook' || session.source === 'stepflow') {
@@ -638,126 +784,17 @@ export class SessionManager {
638
784
  // ---------------------------------------------------------------------------
639
785
  /**
640
786
  * Spawn (or re-spawn) a Claude CLI process for a session.
641
- * Wires up all event handlers for streaming text, tools, prompts, and auto-restart.
787
+ * Delegates to SessionLifecycle.
642
788
  */
643
789
  startClaude(sessionId) {
644
- const session = this.sessions.get(sessionId);
645
- if (!session)
646
- return false;
647
- // Clear stopped flag on explicit start
648
- session._stoppedByUser = false;
649
- // Kill existing process if any — remove listeners first to prevent the
650
- // old process's exit handler from clobbering the new process reference
651
- // and triggering an unwanted auto-restart cycle.
652
- if (session.claudeProcess) {
653
- session.claudeProcess.removeAllListeners();
654
- session.claudeProcess.stop();
655
- }
656
- // Derive a session-scoped token instead of forwarding the master auth token.
657
- // This limits child process privileges to approve/deny for their own session only.
658
- const sessionToken = this._authToken
659
- ? deriveSessionToken(this._authToken, sessionId)
660
- : '';
661
- // Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
662
- // (current canonical name) are set to the same derived value for backward compatibility.
663
- const extraEnv = {
664
- CODEKIN_SESSION_ID: sessionId,
665
- CODEKIN_PORT: String(this._serverPort || PORT),
666
- CODEKIN_TOKEN: sessionToken,
667
- CODEKIN_AUTH_TOKEN: sessionToken,
668
- CODEKIN_SESSION_TYPE: session.source || 'manual',
669
- ...(session.permissionMode === 'dangerouslySkipPermissions' ? { CODEKIN_SKIP_PERMISSIONS: '1' } : {}),
670
- };
671
- // Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
672
- // even when the session's working directory differs from the project root
673
- // (e.g. worktrees, webhook workspaces). Note: this does NOT control
674
- // session storage path — Claude CLI uses the CWD for that.
675
- if (session.groupDir) {
676
- extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
677
- }
678
- else if (process.env.CLAUDE_PROJECT_DIR) {
679
- extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
680
- }
681
- // When claudeSessionId exists, the session has run before and a JSONL file
682
- // exists on disk. Use --resume (not --session-id) to continue it — --session-id
683
- // creates a *new* session and fails with "already in use" if the JSONL exists.
684
- const resume = !!session.claudeSessionId;
685
- // Build comprehensive allowedTools from session-level overrides + registry approvals
686
- const repoDir = session.groupDir ?? session.workingDir;
687
- const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
688
- const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
689
- const cp = new ClaudeProcess(session.workingDir, {
690
- sessionId: session.claudeSessionId || undefined,
691
- extraEnv,
692
- model: session.model,
693
- permissionMode: session.permissionMode,
694
- resume,
695
- allowedTools: mergedAllowedTools,
696
- });
697
- this.wireClaudeEvents(cp, session, sessionId);
698
- cp.start();
699
- session.claudeProcess = cp;
700
- this._globalBroadcast?.({ type: 'sessions_updated' });
701
- const startMsg = { type: 'claude_started', sessionId };
702
- this.addToHistory(session, startMsg);
703
- this.broadcast(session, startMsg);
704
- return true;
790
+ return this.sessionLifecycle.startClaude(sessionId);
705
791
  }
706
792
  /**
707
- * Wait for a session's Claude process to emit its system_init event,
708
- * indicating it is ready to accept input. Resolves immediately if the
709
- * session already has a claudeSessionId (process previously initialized).
710
- * Times out after `timeoutMs` (default 30s) to avoid hanging indefinitely.
793
+ * Wait for a session's Claude process to emit its system_init event.
794
+ * Delegates to SessionLifecycle.
711
795
  */
712
796
  waitForReady(sessionId, timeoutMs = 30_000) {
713
- const session = this.sessions.get(sessionId);
714
- if (!session?.claudeProcess)
715
- return Promise.resolve();
716
- // If the process already completed init in a prior turn, resolve immediately
717
- if (session.claudeSessionId)
718
- return Promise.resolve();
719
- return new Promise((resolve) => {
720
- const timer = setTimeout(() => {
721
- console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`);
722
- resolve();
723
- }, timeoutMs);
724
- session.claudeProcess.once('system_init', () => {
725
- clearTimeout(timer);
726
- resolve();
727
- });
728
- });
729
- }
730
- /**
731
- * Attach all ClaudeProcess event listeners for a session.
732
- * Extracted from startClaude() to keep that method focused on process setup.
733
- */
734
- wireClaudeEvents(cp, session, sessionId) {
735
- cp.on('system_init', (model) => this.onSystemInit(cp, session, model));
736
- cp.on('text', (text) => this.onTextEvent(session, sessionId, text));
737
- cp.on('thinking', (summary) => this.onThinkingEvent(session, summary));
738
- cp.on('tool_output', (content, isError) => this.onToolOutputEvent(session, content, isError));
739
- cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
740
- cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
741
- cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
742
- cp.on('planning_mode', (active) => {
743
- // Route EnterPlanMode through PlanManager for UI state tracking.
744
- // ExitPlanMode (active=false) is ignored here — the PreToolUse hook
745
- // is the enforcement gate, and it calls handleExitPlanModeApproval()
746
- // which transitions PlanManager to 'reviewing'.
747
- if (active) {
748
- session.planManager.onEnterPlanMode();
749
- }
750
- // ExitPlanMode stream event intentionally ignored — hook handles it.
751
- });
752
- cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
753
- cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
754
- cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
755
- cp.on('result', (result, isError) => {
756
- session.planManager.onTurnEnd();
757
- this.handleClaudeResult(session, sessionId, result, isError);
758
- });
759
- cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
760
- cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(cp, session, sessionId, code, signal); });
797
+ return this.sessionLifecycle.waitForReady(sessionId, timeoutMs);
761
798
  }
762
799
  /** Broadcast a message and add it to the session's output history. */
763
800
  broadcastAndHistory(session, msg) {
@@ -807,89 +844,6 @@ export class SessionManager {
807
844
  onToolDoneEvent(session, toolName, summary) {
808
845
  this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
809
846
  }
810
- onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
811
- const promptMsg = {
812
- type: 'prompt',
813
- promptType,
814
- question,
815
- options,
816
- multiSelect,
817
- toolName,
818
- toolInput,
819
- requestId,
820
- ...(questions ? { questions } : {}),
821
- };
822
- if (requestId) {
823
- session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
824
- }
825
- this.broadcast(session, promptMsg);
826
- // Notify prompt listeners (orchestrator, child monitor, etc.)
827
- for (const listener of this._promptListeners) {
828
- try {
829
- listener(session.id, promptType, toolName, requestId);
830
- }
831
- catch { /* listener error */ }
832
- }
833
- }
834
- onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
835
- if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
836
- console.warn(`[control_request] Rejected invalid requestId: ${JSON.stringify(requestId)}`);
837
- return;
838
- }
839
- console.log(`[control_request] session=${sessionId} tool=${toolName} requestId=${requestId}`);
840
- if (this.resolveAutoApproval(session, toolName, toolInput) !== 'prompt') {
841
- console.log(`[control_request] auto-approved: ${toolName}`);
842
- cp.sendControlResponse(requestId, 'allow');
843
- return;
844
- }
845
- // Prevent double-gating: if a PreToolUse hook is already handling approval
846
- // for this tool, auto-approve the control_request to avoid duplicate entries.
847
- // Without this, both pendingToolApprovals and pendingControlRequests contain
848
- // entries for the same tool invocation, causing stale-entry races when the
849
- // orchestrator tries to respond via the REST API.
850
- for (const pending of session.pendingToolApprovals.values()) {
851
- if (pending.toolName === toolName) {
852
- console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
853
- cp.sendControlResponse(requestId, 'allow');
854
- return;
855
- }
856
- }
857
- const question = this.summarizeToolPermission(toolName, toolInput);
858
- const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
859
- const options = [
860
- { label: 'Allow', value: 'allow' },
861
- ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
862
- { label: 'Deny', value: 'deny' },
863
- ];
864
- const promptMsg = {
865
- type: 'prompt',
866
- promptType: 'permission',
867
- question,
868
- options,
869
- toolName,
870
- toolInput,
871
- requestId,
872
- };
873
- session.pendingControlRequests.set(requestId, { requestId, toolName, toolInput, promptMsg });
874
- if (session.clients.size > 0) {
875
- this.broadcast(session, promptMsg);
876
- }
877
- else {
878
- console.log(`[control_request] no clients connected, waiting for client to join: ${toolName}`);
879
- this._globalBroadcast?.({
880
- ...promptMsg,
881
- sessionId,
882
- sessionName: session.name,
883
- });
884
- }
885
- // Notify prompt listeners (orchestrator, child monitor, etc.)
886
- for (const listener of this._promptListeners) {
887
- try {
888
- listener(sessionId, 'permission', toolName, requestId);
889
- }
890
- catch { /* listener error */ }
891
- }
892
- }
893
847
  /**
894
848
  * Handle a Claude process 'result' event: update session state, apply API
895
849
  * retry logic for transient errors, broadcast result to clients, and trigger
@@ -939,15 +893,20 @@ export class SessionManager {
939
893
  handleApiRetry(session, sessionId, result) {
940
894
  if (!session._lastUserInput || !this.isRetryableApiError(result)) {
941
895
  session._apiRetryCount = 0;
896
+ session._apiRetryScheduled = false;
942
897
  return false;
943
898
  }
944
899
  // Skip retry if the original input is older than 60 seconds — context has likely moved on
945
900
  if (session._lastUserInputAt && Date.now() - session._lastUserInputAt > 60_000) {
946
901
  console.log(`[api-retry] skipping stale retry for session=${sessionId} (input age=${Math.round((Date.now() - session._lastUserInputAt) / 1000)}s)`);
947
902
  session._apiRetryCount = 0;
903
+ session._apiRetryScheduled = false;
948
904
  return false;
949
905
  }
950
906
  if (session._apiRetryCount < MAX_API_RETRIES) {
907
+ // Prevent duplicate scheduling from concurrent error paths
908
+ if (session._apiRetryScheduled)
909
+ return true;
951
910
  session._apiRetryCount++;
952
911
  const attempt = session._apiRetryCount;
953
912
  const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -961,8 +920,10 @@ export class SessionManager {
961
920
  console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
962
921
  if (session._apiRetryTimer)
963
922
  clearTimeout(session._apiRetryTimer);
923
+ session._apiRetryScheduled = true;
964
924
  session._apiRetryTimer = setTimeout(() => {
965
925
  session._apiRetryTimer = undefined;
926
+ session._apiRetryScheduled = false;
966
927
  if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
967
928
  return;
968
929
  console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
@@ -979,6 +940,7 @@ export class SessionManager {
979
940
  this.addToHistory(session, exhaustedMsg);
980
941
  this.broadcast(session, exhaustedMsg);
981
942
  session._apiRetryCount = 0;
943
+ session._apiRetryScheduled = false;
982
944
  return false;
983
945
  }
984
946
  /**
@@ -987,6 +949,7 @@ export class SessionManager {
987
949
  */
988
950
  finalizeResult(session, sessionId, result, isError) {
989
951
  session._apiRetryCount = 0;
952
+ session._apiRetryScheduled = false;
990
953
  session._lastUserInput = undefined;
991
954
  session._lastUserInputAt = undefined;
992
955
  if (isError) {
@@ -1020,103 +983,9 @@ export class SessionManager {
1020
983
  void this.sessionNaming.executeSessionNaming(sessionId);
1021
984
  }
1022
985
  }
1023
- /**
1024
- * Handle a Claude process 'exit' event: clean up state, notify exit listeners,
1025
- * and either auto-restart (within limits) or broadcast the final exit message.
1026
- *
1027
- * Uses evaluateRestart() for the restart decision, keeping this method focused
1028
- * on state updates, listener notification, and message broadcasting.
1029
- */
1030
- handleClaudeExit(exitedProcess, session, sessionId, code, signal) {
1031
- session.claudeProcess = null;
1032
- session.isProcessing = false;
1033
- session.planManager.reset();
1034
- this._globalBroadcast?.({ type: 'sessions_updated' });
1035
- // "Session ID is already in use" means another process holds the lock.
1036
- // Retrying with the same session ID will fail every time, so treat this
1037
- // as a non-restartable exit (same as stopped-by-user).
1038
- const sessionConflict = exitedProcess.hasSessionConflict();
1039
- // If the process exited without ever producing stdout output, --resume
1040
- // hung on a broken/stale session. Clear claudeSessionId so the next
1041
- // restart attempt uses a fresh session instead of retrying the same
1042
- // broken resume — which would just hang again.
1043
- if (!exitedProcess.hadOutput() && session.claudeSessionId) {
1044
- console.warn(`[restart] Session ${sessionId} produced no output before exit — clearing claudeSessionId to force fresh session`);
1045
- session.claudeSessionId = null;
1046
- }
1047
- const action = evaluateRestart({
1048
- restartCount: session.restartCount,
1049
- lastRestartAt: session.lastRestartAt,
1050
- stoppedByUser: session._stoppedByUser || sessionConflict,
1051
- });
1052
- if (action.kind === 'stopped_by_user') {
1053
- for (const listener of this._exitListeners) {
1054
- try {
1055
- listener(sessionId, code, signal, false);
1056
- }
1057
- catch { /* listener error */ }
1058
- }
1059
- const text = sessionConflict
1060
- ? 'Claude process exited: session ID is already in use by another process. Please restart manually.'
1061
- : `Claude process exited: code=${code}, signal=${signal}`;
1062
- const msg = { type: 'system_message', subtype: 'exit', text };
1063
- this.addToHistory(session, msg);
1064
- this.broadcast(session, msg);
1065
- this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
1066
- return;
1067
- }
1068
- if (action.kind === 'restart') {
1069
- session.restartCount = action.updatedCount;
1070
- session.lastRestartAt = action.updatedLastRestartAt;
1071
- for (const listener of this._exitListeners) {
1072
- try {
1073
- listener(sessionId, code, signal, true);
1074
- }
1075
- catch { /* listener error */ }
1076
- }
1077
- const msg = {
1078
- type: 'system_message',
1079
- subtype: 'restart',
1080
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
1081
- };
1082
- this.addToHistory(session, msg);
1083
- this.broadcast(session, msg);
1084
- setTimeout(() => {
1085
- // Verify session still exists and hasn't been stopped
1086
- if (!this.sessions.has(sessionId) || session._stoppedByUser)
1087
- return;
1088
- // startClaude uses --resume when claudeSessionId exists, so the CLI
1089
- // picks up the full conversation history from the JSONL automatically.
1090
- this.startClaude(sessionId);
1091
- // Fallback: if claudeSessionId was already null (fresh session that
1092
- // crashed before system_init), inject a context summary so the new
1093
- // session has some awareness of prior conversation.
1094
- if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
1095
- session.claudeProcess.once('system_init', () => {
1096
- const context = this.buildSessionContext(session);
1097
- if (context) {
1098
- session.claudeProcess?.sendMessage(context + '\n\n[Session resumed after process restart. Continue where you left off. If you were in the middle of a task, resume it.]');
1099
- }
1100
- });
1101
- }
1102
- }, action.delayMs);
1103
- return;
1104
- }
1105
- // action.kind === 'exhausted'
1106
- for (const listener of this._exitListeners) {
1107
- try {
1108
- listener(sessionId, code, signal, false);
1109
- }
1110
- catch { /* listener error */ }
1111
- }
1112
- const msg = {
1113
- type: 'system_message',
1114
- subtype: 'error',
1115
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
1116
- };
1117
- this.addToHistory(session, msg);
1118
- this.broadcast(session, msg);
1119
- this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
986
+ /** Handle Claude process exit. Delegates to SessionLifecycle. @internal — used by tests. */
987
+ handleClaudeExit(...args) {
988
+ this.sessionLifecycle.handleClaudeExit(...args);
1120
989
  }
1121
990
  /**
1122
991
  * Send user input to a session's Claude process.
@@ -1172,436 +1041,19 @@ export class SessionManager {
1172
1041
  session.claudeProcess?.sendMessage(data);
1173
1042
  }
1174
1043
  /**
1175
- * Route a user's prompt response to the correct handler: pending tool approval
1176
- * (from PermissionRequest hook), pending control request (from control_request
1177
- * fallback path), or plain message fallback.
1044
+ * Route a user's prompt response to the correct handler.
1045
+ * Delegates to PromptRouter.
1178
1046
  */
1179
1047
  sendPromptResponse(sessionId, value, requestId) {
1180
- const session = this.sessions.get(sessionId);
1181
- if (!session)
1182
- return;
1183
- session._lastActivityAt = Date.now();
1184
- // ExitPlanMode approvals are handled through the normal pendingToolApprovals
1185
- // path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
1186
- // Check for pending tool approval from PreToolUse hook
1187
- if (!requestId) {
1188
- const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
1189
- if (totalPending === 1) {
1190
- // Exactly one pending prompt — safe to infer the target
1191
- const soleApproval = session.pendingToolApprovals.size === 1
1192
- ? session.pendingToolApprovals.values().next().value
1193
- : undefined;
1194
- if (soleApproval) {
1195
- console.warn(`[prompt_response] no requestId, routing to sole pending tool approval: ${soleApproval.toolName}`);
1196
- this.resolveToolApproval(session, soleApproval, value);
1197
- return;
1198
- }
1199
- const soleControl = session.pendingControlRequests.size === 1
1200
- ? session.pendingControlRequests.values().next().value
1201
- : undefined;
1202
- if (soleControl) {
1203
- console.warn(`[prompt_response] no requestId, routing to sole pending control request: ${soleControl.toolName}`);
1204
- requestId = soleControl.requestId;
1205
- }
1206
- }
1207
- else if (totalPending > 1) {
1208
- console.warn(`[prompt_response] no requestId with ${totalPending} pending prompts — rejecting to prevent misrouted response`);
1209
- this.broadcast(session, {
1210
- type: 'system_message',
1211
- subtype: 'error',
1212
- text: 'Prompt response could not be routed: multiple prompts pending. Please refresh and try again.',
1213
- });
1214
- return;
1215
- }
1216
- else {
1217
- console.warn(`[prompt_response] no requestId, no pending prompts — forwarding as user message`);
1218
- }
1219
- }
1220
- const approval = requestId ? session.pendingToolApprovals.get(requestId) : undefined;
1221
- if (approval) {
1222
- this.resolveToolApproval(session, approval, value);
1223
- return;
1224
- }
1225
- if (!session.claudeProcess?.isAlive())
1226
- return;
1227
- // Find matching pending control request
1228
- const pending = requestId ? session.pendingControlRequests.get(requestId) : undefined;
1229
- if (pending) {
1230
- session.pendingControlRequests.delete(pending.requestId);
1231
- // Dismiss prompt on all other clients viewing this session
1232
- this.broadcast(session, { type: 'prompt_dismiss', requestId: pending.requestId });
1233
- if (pending.toolName === 'AskUserQuestion') {
1234
- this.handleAskUserQuestion(session, pending, value);
1235
- }
1236
- else {
1237
- this.sendControlResponseForRequest(session, pending, value);
1238
- }
1239
- }
1240
- else {
1241
- // Fallback: no pending control request, send as plain user message
1242
- const answer = Array.isArray(value) ? value.join(', ') : value;
1243
- session.claudeProcess.sendMessage(answer);
1244
- }
1245
- }
1246
- /** Decode the allow/deny/always/pattern intent from a prompt response value. */
1247
- decodeApprovalValue(value) {
1248
- const first = Array.isArray(value) ? value[0] : value;
1249
- return {
1250
- isDeny: first === 'deny',
1251
- isAlwaysAllow: first === 'always_allow',
1252
- isApprovePattern: first === 'approve_pattern',
1253
- };
1254
- }
1255
- /** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
1256
- resolveToolApproval(session, approval, value) {
1257
- // AskUserQuestion: the value IS the user's answer, not a permission decision
1258
- if (approval.toolName === 'AskUserQuestion') {
1259
- const answer = Array.isArray(value) ? value.join(', ') : value;
1260
- console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
1261
- approval.resolve({ allow: true, always: false, answer });
1262
- session.pendingToolApprovals.delete(approval.requestId);
1263
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1264
- return;
1265
- }
1266
- // ExitPlanMode: route through PlanManager for state tracking.
1267
- // The hook will convert allow→deny-with-approval-message (CLI workaround).
1268
- if (approval.toolName === 'ExitPlanMode') {
1269
- const first = Array.isArray(value) ? value[0] : value;
1270
- const isDeny = first === 'deny';
1271
- if (isDeny) {
1272
- // Extract feedback text if present (value may be ['deny', 'feedback text'])
1273
- const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
1274
- const reason = session.planManager.deny(approval.requestId, feedback);
1275
- console.log(`[plan-approval] denied: ${reason}`);
1276
- approval.resolve({ allow: false, always: false, answer: reason || undefined });
1277
- }
1278
- else {
1279
- session.planManager.approve(approval.requestId);
1280
- console.log(`[plan-approval] approved`);
1281
- approval.resolve({ allow: true, always: false });
1282
- }
1283
- session.pendingToolApprovals.delete(approval.requestId);
1284
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1285
- return;
1286
- }
1287
- const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1288
- if (isAlwaysAllow && !isDeny) {
1289
- this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
1290
- }
1291
- if (isApprovePattern && !isDeny) {
1292
- this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
1293
- }
1294
- console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
1295
- approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
1296
- session.pendingToolApprovals.delete(approval.requestId);
1297
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1298
- }
1299
- /**
1300
- * Send an AskUserQuestion control response, mapping the user's answer(s) into
1301
- * the structured answers map the tool expects.
1302
- */
1303
- handleAskUserQuestion(session, pending, value) {
1304
- const questions = pending.toolInput?.questions;
1305
- const updatedInput = { ...pending.toolInput };
1306
- let answers = {};
1307
- if (typeof value === 'string') {
1308
- // Try parsing as JSON answers map (multi-question flow)
1309
- try {
1310
- const parsed = JSON.parse(value);
1311
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1312
- answers = parsed;
1313
- }
1314
- else if (Array.isArray(questions) && questions.length > 0) {
1315
- answers[questions[0].question] = value;
1316
- }
1317
- }
1318
- catch {
1319
- // Plain string answer — map to first question
1320
- if (Array.isArray(questions) && questions.length > 0) {
1321
- answers[questions[0].question] = value;
1322
- }
1323
- }
1324
- }
1325
- else if (Array.isArray(value) && Array.isArray(questions) && questions.length > 0) {
1326
- // Array of answers — map to first question (multi-select single question)
1327
- answers[questions[0].question] = value.join(', ');
1328
- }
1329
- updatedInput.answers = answers;
1330
- session.claudeProcess.sendControlResponse(pending.requestId, 'allow', updatedInput);
1331
- }
1332
- /** Send a permission control response (allow/always_allow/approve_pattern/deny). */
1333
- sendControlResponseForRequest(session, pending, value) {
1334
- const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1335
- if (isAlwaysAllow) {
1336
- this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
1337
- }
1338
- if (isApprovePattern) {
1339
- this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
1340
- }
1341
- const behavior = isDeny ? 'deny' : 'allow';
1342
- session.claudeProcess.sendControlResponse(pending.requestId, behavior);
1048
+ this.promptRouter.sendPromptResponse(sessionId, value, requestId);
1343
1049
  }
1344
1050
  /**
1345
1051
  * Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
1346
1052
  * and returns a Promise that resolves when the user approves/denies.
1053
+ * Delegates to PromptRouter.
1347
1054
  */
1348
1055
  requestToolApproval(sessionId, toolName, toolInput) {
1349
- const session = this.sessions.get(sessionId);
1350
- if (!session) {
1351
- console.log(`[tool-approval] session not found: ${sessionId}`);
1352
- return Promise.resolve({ allow: false, always: false });
1353
- }
1354
- const autoResult = this.resolveAutoApproval(session, toolName, toolInput);
1355
- if (autoResult === 'registry') {
1356
- console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
1357
- return Promise.resolve({ allow: true, always: true });
1358
- }
1359
- if (autoResult === 'session') {
1360
- console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
1361
- return Promise.resolve({ allow: true, always: false });
1362
- }
1363
- if (autoResult === 'headless') {
1364
- console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
1365
- return Promise.resolve({ allow: true, always: false });
1366
- }
1367
- console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
1368
- // ExitPlanMode: route through PlanManager state machine for plan-specific
1369
- // approval UI. The hook blocks until we resolve the promise.
1370
- if (toolName === 'ExitPlanMode') {
1371
- return this.handleExitPlanModeApproval(session, sessionId);
1372
- }
1373
- // Prevent double-gating: if a control_request already created a pending
1374
- // entry for this tool, auto-approve the control_request and let the hook
1375
- // take over as the sole approval gate. This is the reverse of the check
1376
- // in onControlRequestEvent (which handles hook-first ordering).
1377
- for (const [reqId, pending] of session.pendingControlRequests) {
1378
- if (pending.toolName === toolName) {
1379
- console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
1380
- session.claudeProcess?.sendControlResponse(reqId, 'allow');
1381
- session.pendingControlRequests.delete(reqId);
1382
- this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
1383
- break;
1384
- }
1385
- }
1386
- // AskUserQuestion: show a question prompt and collect the answer text,
1387
- // rather than a permission prompt with Allow/Deny buttons.
1388
- const isQuestion = toolName === 'AskUserQuestion';
1389
- return new Promise((resolve) => {
1390
- // Holder lets wrappedResolve reference the timeout before it's assigned
1391
- const timer = { id: null };
1392
- const wrappedResolve = (result) => {
1393
- if (timer.id)
1394
- clearTimeout(timer.id);
1395
- resolve(result);
1396
- };
1397
- const approvalRequestId = randomUUID();
1398
- // Timeout to prevent leaked promises if client disconnects after prompt is sent
1399
- timer.id = setTimeout(() => {
1400
- if (session.pendingToolApprovals.has(approvalRequestId)) {
1401
- console.log(`[tool-approval] timed out for ${toolName}`);
1402
- session.pendingToolApprovals.delete(approvalRequestId);
1403
- // Dismiss the stale prompt in all clients so they don't inject
1404
- // "allow"/"deny" as plain text after the timeout
1405
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
1406
- resolve({ allow: false, always: false });
1407
- }
1408
- }, 300_000); // 5 min for all approval types — prevents premature auto-deny when user is reading or tabbed away
1409
- let promptMsg;
1410
- if (isQuestion) {
1411
- // AskUserQuestion: extract structured questions from toolInput.questions
1412
- // and pass them through so PromptButtons can render the multi-question flow.
1413
- const rawQuestions = toolInput.questions;
1414
- const structuredQuestions = Array.isArray(rawQuestions)
1415
- ? rawQuestions.map(q => ({
1416
- question: q.question,
1417
- header: q.header,
1418
- multiSelect: q.multiSelect ?? false,
1419
- options: (q.options || []).map((opt) => ({
1420
- label: opt.label,
1421
- value: opt.value ?? opt.label,
1422
- description: opt.description,
1423
- })),
1424
- }))
1425
- : undefined;
1426
- const firstQ = structuredQuestions?.[0];
1427
- promptMsg = {
1428
- type: 'prompt',
1429
- promptType: 'question',
1430
- question: firstQ?.question || 'Answer the question',
1431
- options: firstQ?.options || [],
1432
- multiSelect: firstQ?.multiSelect,
1433
- toolName,
1434
- toolInput,
1435
- requestId: approvalRequestId,
1436
- ...(structuredQuestions ? { questions: structuredQuestions } : {}),
1437
- };
1438
- }
1439
- else {
1440
- const question = this.summarizeToolPermission(toolName, toolInput);
1441
- const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
1442
- const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
1443
- const options = [
1444
- { label: 'Allow', value: 'allow' },
1445
- ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
1446
- { label: 'Deny', value: 'deny' },
1447
- ];
1448
- promptMsg = {
1449
- type: 'prompt',
1450
- promptType: 'permission',
1451
- question,
1452
- options,
1453
- toolName,
1454
- toolInput,
1455
- requestId: approvalRequestId,
1456
- ...(approvePattern ? { approvePattern } : {}),
1457
- };
1458
- }
1459
- session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
1460
- if (session.clients.size > 0) {
1461
- this.broadcast(session, promptMsg);
1462
- }
1463
- else {
1464
- // No clients connected — DON'T auto-deny. Instead, wait for a client
1465
- // to join this session (the prompt will be re-broadcast in join()).
1466
- // Send a global notification so the user sees a waiting indicator.
1467
- console.log(`[tool-approval] no clients connected, waiting for client to join (timeout 300s): ${toolName}`);
1468
- this._globalBroadcast?.({
1469
- ...promptMsg,
1470
- sessionId,
1471
- sessionName: session.name,
1472
- });
1473
- }
1474
- // Notify prompt listeners (orchestrator, child monitor, etc.)
1475
- for (const listener of this._promptListeners) {
1476
- try {
1477
- listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
1478
- }
1479
- catch { /* listener error */ }
1480
- }
1481
- });
1482
- }
1483
- /**
1484
- * Handle ExitPlanMode approval through PlanManager.
1485
- * Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
1486
- * until the user responds. On approve, returns allow:true (the hook will use
1487
- * the deny-with-approval-message workaround). On deny, returns allow:false.
1488
- */
1489
- handleExitPlanModeApproval(session, sessionId) {
1490
- const reviewId = session.planManager.onExitPlanModeRequested();
1491
- if (!reviewId) {
1492
- // Not in planning state — fall through to allow (CLI handles natively)
1493
- console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
1494
- return Promise.resolve({ allow: true, always: false });
1495
- }
1496
- return new Promise((resolve) => {
1497
- const timer = { id: null };
1498
- const wrappedResolve = (result) => {
1499
- if (timer.id)
1500
- clearTimeout(timer.id);
1501
- resolve(result);
1502
- };
1503
- // Timeout: auto-deny after 5 minutes to prevent leaked promises
1504
- timer.id = setTimeout(() => {
1505
- if (session.pendingToolApprovals.has(reviewId)) {
1506
- console.log(`[plan-approval] timed out, auto-denying`);
1507
- session.pendingToolApprovals.delete(reviewId);
1508
- session.planManager.deny(reviewId);
1509
- this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
1510
- resolve({ allow: false, always: false });
1511
- }
1512
- }, 300_000);
1513
- const promptMsg = {
1514
- type: 'prompt',
1515
- promptType: 'permission',
1516
- question: 'Approve plan and start implementation?',
1517
- options: [
1518
- { label: 'Approve', value: 'allow' },
1519
- { label: 'Reject', value: 'deny' },
1520
- ],
1521
- toolName: 'ExitPlanMode',
1522
- requestId: reviewId,
1523
- };
1524
- session.pendingToolApprovals.set(reviewId, {
1525
- resolve: wrappedResolve,
1526
- toolName: 'ExitPlanMode',
1527
- toolInput: {},
1528
- requestId: reviewId,
1529
- promptMsg,
1530
- });
1531
- this.broadcast(session, promptMsg);
1532
- if (session.clients.size === 0) {
1533
- this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
1534
- }
1535
- for (const listener of this._promptListeners) {
1536
- try {
1537
- listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
1538
- }
1539
- catch { /* listener error */ }
1540
- }
1541
- });
1542
- }
1543
- /**
1544
- * Check if a tool invocation can be auto-approved without prompting the user.
1545
- * Returns 'registry' if matched by auto-approval rules, 'session' if matched
1546
- * by the session's allowedTools list, 'headless' if the session has no clients
1547
- * and is a non-interactive source, or 'prompt' if the user needs to decide.
1548
- */
1549
- resolveAutoApproval(session, toolName, toolInput) {
1550
- if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
1551
- return 'registry';
1552
- }
1553
- if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
1554
- return 'session';
1555
- }
1556
- if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'orchestrator')) {
1557
- return 'headless';
1558
- }
1559
- return 'prompt';
1560
- }
1561
- /**
1562
- * Check if a tool invocation matches any of the session's allowedTools patterns.
1563
- * Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
1564
- * Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
1565
- */
1566
- matchesAllowedTools(allowedTools, toolName, toolInput) {
1567
- for (const pattern of allowedTools) {
1568
- // Simple tool name match: 'WebFetch', 'Read', etc.
1569
- if (pattern === toolName)
1570
- return true;
1571
- // Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
1572
- const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
1573
- if (match) {
1574
- const [, patternTool, prefix] = match;
1575
- if (patternTool !== toolName)
1576
- continue;
1577
- // For Bash, check command prefix
1578
- if (toolName === 'Bash') {
1579
- const cmd = String(toolInput.command || '').trimStart();
1580
- if (cmd === prefix || cmd.startsWith(prefix + ' '))
1581
- return true;
1582
- }
1583
- }
1584
- }
1585
- return false;
1586
- }
1587
- /** Build a human-readable prompt string for a tool permission dialog. */
1588
- summarizeToolPermission(toolName, toolInput) {
1589
- switch (toolName) {
1590
- case 'Bash': {
1591
- const cmd = String(toolInput.command || '');
1592
- const firstLine = cmd.split('\n')[0];
1593
- const display = firstLine.length < cmd.length ? `${firstLine}...` : cmd;
1594
- return `Allow Bash? \`$ ${display}\``;
1595
- }
1596
- case 'Task':
1597
- return `Allow Task? ${String(toolInput.description || toolName)}`;
1598
- case 'Read': {
1599
- const filePath = String(toolInput.file_path || '');
1600
- return `Allow Read? \`${filePath}\``;
1601
- }
1602
- default:
1603
- return `Allow ${toolName}?`;
1604
- }
1056
+ return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
1605
1057
  }
1606
1058
  /** Update the model for a session and restart Claude with the new model. */
1607
1059
  setModel(sessionId, model) {
@@ -1610,15 +1062,16 @@ export class SessionManager {
1610
1062
  return false;
1611
1063
  session.model = model || undefined;
1612
1064
  this.persistToDiskDebounced();
1613
- // Restart Claude with the new model if it's running
1065
+ // Restart Claude with the new model if it's running.
1066
+ // Use stopClaudeAndWait to ensure the old process fully exits before
1067
+ // spawning a new one — avoids concurrent processes in the same worktree.
1614
1068
  if (session.claudeProcess?.isAlive()) {
1615
- this.stopClaude(sessionId);
1616
- session._stoppedByUser = false;
1617
- setTimeout(() => {
1618
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1069
+ void this.stopClaudeAndWait(sessionId).then(() => {
1070
+ if (this.sessions.has(sessionId)) {
1071
+ session._stoppedByUser = false;
1619
1072
  this.startClaude(sessionId);
1620
1073
  }
1621
- }, 500);
1074
+ });
1622
1075
  }
1623
1076
  return true;
1624
1077
  }
@@ -1639,49 +1092,29 @@ export class SessionManager {
1639
1092
  const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
1640
1093
  this.addToHistory(session, sysMsg);
1641
1094
  this.broadcast(session, sysMsg);
1642
- // Restart Claude with the new permission mode if it's running
1095
+ // Restart Claude with the new permission mode if it's running.
1096
+ // Use stopClaudeAndWait to ensure the old process fully exits before
1097
+ // spawning a new one — avoids concurrent processes in the same worktree.
1643
1098
  if (session.claudeProcess?.isAlive()) {
1644
- this.stopClaude(sessionId);
1645
- session._stoppedByUser = false;
1646
- setTimeout(() => {
1647
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1099
+ void this.stopClaudeAndWait(sessionId).then(() => {
1100
+ if (this.sessions.has(sessionId)) {
1101
+ session._stoppedByUser = false;
1648
1102
  this.startClaude(sessionId);
1649
1103
  }
1650
- }, 500);
1104
+ });
1651
1105
  }
1652
1106
  return true;
1653
1107
  }
1108
+ /** Stop the Claude process for a session. Delegates to SessionLifecycle. */
1654
1109
  stopClaude(sessionId) {
1655
- const session = this.sessions.get(sessionId);
1656
- if (session?.claudeProcess) {
1657
- session._stoppedByUser = true;
1658
- if (session._apiRetryTimer)
1659
- clearTimeout(session._apiRetryTimer);
1660
- session.claudeProcess.removeAllListeners();
1661
- session.claudeProcess.stop();
1662
- session.claudeProcess = null;
1663
- this.broadcast(session, { type: 'claude_stopped' });
1664
- }
1110
+ this.sessionLifecycle.stopClaude(sessionId);
1665
1111
  }
1666
1112
  /**
1667
1113
  * Stop the Claude process and wait for it to fully exit before resolving.
1668
- * This prevents race conditions when restarting with the same session ID
1669
- * (e.g. during mid-session worktree migration).
1114
+ * Delegates to SessionLifecycle.
1670
1115
  */
1671
1116
  async stopClaudeAndWait(sessionId) {
1672
- const session = this.sessions.get(sessionId);
1673
- if (!session?.claudeProcess)
1674
- return;
1675
- const cp = session.claudeProcess;
1676
- session._stoppedByUser = true;
1677
- if (session._apiRetryTimer)
1678
- clearTimeout(session._apiRetryTimer);
1679
- cp.removeAllListeners();
1680
- cp.stop();
1681
- session.claudeProcess = null;
1682
- this.broadcast(session, { type: 'claude_stopped' });
1683
- // Wait for the underlying OS process to fully exit
1684
- await cp.waitForExit();
1117
+ return this.sessionLifecycle.stopClaudeAndWait(sessionId);
1685
1118
  }
1686
1119
  // ---------------------------------------------------------------------------
1687
1120
  // Helpers