codekin 0.5.3 → 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 (48) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{index-84JYN21S.js → index-BFkKlY3O.js} +46 -42
  3. package/dist/assets/index-CjEQkT2b.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/server/dist/approval-manager.js +1 -1
  7. package/server/dist/approval-manager.js.map +1 -1
  8. package/server/dist/auth-routes.js +31 -1
  9. package/server/dist/auth-routes.js.map +1 -1
  10. package/server/dist/claude-process.d.ts +26 -0
  11. package/server/dist/claude-process.js +111 -18
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/config.d.ts +8 -0
  14. package/server/dist/config.js +19 -1
  15. package/server/dist/config.js.map +1 -1
  16. package/server/dist/native-permissions.js +1 -1
  17. package/server/dist/native-permissions.js.map +1 -1
  18. package/server/dist/orchestrator-children.js +22 -6
  19. package/server/dist/orchestrator-children.js.map +1 -1
  20. package/server/dist/orchestrator-reports.d.ts +0 -4
  21. package/server/dist/orchestrator-reports.js +6 -12
  22. package/server/dist/orchestrator-reports.js.map +1 -1
  23. package/server/dist/prompt-router.d.ts +95 -0
  24. package/server/dist/prompt-router.js +577 -0
  25. package/server/dist/prompt-router.js.map +1 -0
  26. package/server/dist/session-lifecycle.d.ts +73 -0
  27. package/server/dist/session-lifecycle.js +387 -0
  28. package/server/dist/session-lifecycle.js.map +1 -0
  29. package/server/dist/session-manager.d.ts +33 -60
  30. package/server/dist/session-manager.js +239 -785
  31. package/server/dist/session-manager.js.map +1 -1
  32. package/server/dist/session-naming.js +2 -1
  33. package/server/dist/session-naming.js.map +1 -1
  34. package/server/dist/session-persistence.js +21 -3
  35. package/server/dist/session-persistence.js.map +1 -1
  36. package/server/dist/session-routes.js +18 -0
  37. package/server/dist/session-routes.js.map +1 -1
  38. package/server/dist/tsconfig.tsbuildinfo +1 -1
  39. package/server/dist/types.d.ts +4 -0
  40. package/server/dist/upload-routes.js +3 -1
  41. package/server/dist/upload-routes.js.map +1 -1
  42. package/server/dist/webhook-handler.js +2 -1
  43. package/server/dist/webhook-handler.js.map +1 -1
  44. package/server/dist/ws-message-handler.js +19 -0
  45. package/server/dist/ws-message-handler.js.map +1 -1
  46. package/server/dist/ws-server.js +39 -9
  47. package/server/dist/ws-server.js.map +1 -1
  48. package/dist/assets/index-C0Iuc3iT.css +0 -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),
@@ -120,7 +159,7 @@ export class SessionManager {
120
159
  const now = Date.now();
121
160
  for (const session of this.sessions.values()) {
122
161
  // Skip headless sessions — they are managed by their own lifecycles
123
- if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')
162
+ if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'agent' || session.source === 'orchestrator')
124
163
  continue;
125
164
  // Skip sessions with connected clients or no running process
126
165
  if (session.clients.size > 0 || !session.claudeProcess?.isAlive())
@@ -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;
@@ -143,8 +186,11 @@ export class SessionManager {
143
186
  }
144
187
  }
145
188
  // Prune stale sessions: no process, no clients, older than STALE_SESSION_AGE_MS
189
+ // Agent and orchestrator sessions are exempt — they are long-lived by design.
146
190
  const staleIds = [];
147
191
  for (const session of this.sessions.values()) {
192
+ if (session.source === 'agent' || session.source === 'orchestrator')
193
+ continue;
148
194
  if (session.claudeProcess?.isAlive())
149
195
  continue;
150
196
  if (session.clients.size > 0)
@@ -232,8 +278,16 @@ export class SessionManager {
232
278
  * Create a git worktree for a session. Creates a new branch and worktree
233
279
  * as a sibling directory of the project root.
234
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.
235
289
  */
236
- async createWorktree(sessionId, workingDir) {
290
+ async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
237
291
  const session = this.sessions.get(sessionId);
238
292
  if (!session)
239
293
  return null;
@@ -250,23 +304,72 @@ export class SessionManager {
250
304
  console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
251
305
  return null;
252
306
  }
253
- const prefix = this.getWorktreeBranchPrefix();
254
307
  const shortId = sessionId.slice(0, 8);
255
- const branchName = `${prefix}${shortId}`;
308
+ const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
256
309
  const projectName = path.basename(repoRoot);
257
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
+ }
258
331
  // Clean up stale state from previous failed attempts:
259
332
  // 1. Prune orphaned worktree entries (directory gone but git still tracks it)
260
333
  await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
261
334
  .catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
262
- // 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".
263
338
  await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
264
- .catch(() => { }); // Expected to fail if no prior worktree exists
265
- // 3. Delete the branch if it exists (leftover from a failed worktree add)
266
- await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
267
- .catch((e) => console.debug(`[worktree] branch cleanup (expected if fresh):`, e instanceof Error ? e.message : e));
268
- // Create the worktree with a new branch
269
- 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, {
270
373
  cwd: repoRoot,
271
374
  env,
272
375
  timeout: 15000,
@@ -360,8 +463,11 @@ export class SessionManager {
360
463
  /**
361
464
  * Clean up a git worktree and its branch. Runs asynchronously and logs errors
362
465
  * but never throws — session deletion must not be blocked by cleanup failures.
466
+ * Retries once on failure after a short delay.
363
467
  */
364
- cleanupWorktree(worktreePath, repoDir) {
468
+ cleanupWorktree(worktreePath, repoDir, attempt = 1) {
469
+ const MAX_CLEANUP_ATTEMPTS = 2;
470
+ const RETRY_DELAY_MS = 3000;
365
471
  void (async () => {
366
472
  try {
367
473
  // Resolve the actual repo root (repoDir may itself be a worktree)
@@ -378,13 +484,65 @@ export class SessionManager {
378
484
  console.log(`[worktree] Cleaned up worktree: ${worktreePath}`);
379
485
  // Prune any stale worktree references
380
486
  await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, timeout: 5000 })
381
- .catch(() => { });
487
+ .catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
382
488
  }
383
489
  catch (err) {
384
- 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
+ }
385
509
  }
386
510
  })();
387
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
+ }
388
546
  /** Get the configured worktree branch prefix (defaults to 'wt/'). */
389
547
  getWorktreeBranchPrefix() {
390
548
  return this.archive.getSetting('worktree_branch_prefix', 'wt/');
@@ -421,30 +579,7 @@ export class SessionManager {
421
579
  }
422
580
  /** Get all sessions that have pending prompts (waiting for approval or answer). */
423
581
  getPendingPrompts() {
424
- const results = [];
425
- for (const session of this.sessions.values()) {
426
- const prompts = [];
427
- for (const [reqId, pending] of session.pendingToolApprovals) {
428
- prompts.push({
429
- requestId: reqId,
430
- promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
431
- toolName: pending.toolName,
432
- toolInput: pending.toolInput,
433
- });
434
- }
435
- for (const [reqId, pending] of session.pendingControlRequests) {
436
- prompts.push({
437
- requestId: reqId,
438
- promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
439
- toolName: pending.toolName,
440
- toolInput: pending.toolInput,
441
- });
442
- }
443
- if (prompts.length > 0) {
444
- results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
445
- }
446
- }
447
- return results;
582
+ return this.promptRouter.getPendingPrompts();
448
583
  }
449
584
  /** Clear the isProcessing flag for a session and broadcast the update. */
450
585
  clearProcessingFlag(sessionId) {
@@ -575,15 +710,29 @@ export class SessionManager {
575
710
  clearTimeout(session._namingTimer);
576
711
  if (session._leaveGraceTimer)
577
712
  clearTimeout(session._leaveGraceTimer);
578
- // Kill claude process if running
579
- if (session.claudeProcess) {
580
- session.claudeProcess.stop();
581
- session.claudeProcess = null;
582
- }
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();
583
730
  this.archiveSessionIfWorthSaving(session);
584
- // Clean up git worktree if this session used one
731
+ // Clean up git worktree if this session used one — deferred until process exits
585
732
  if (session.worktreePath) {
586
- 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));
587
736
  }
588
737
  // Clean up webhook workspace directory if applicable
589
738
  if (session.source === 'webhook' || session.source === 'stepflow') {
@@ -635,123 +784,17 @@ export class SessionManager {
635
784
  // ---------------------------------------------------------------------------
636
785
  /**
637
786
  * Spawn (or re-spawn) a Claude CLI process for a session.
638
- * Wires up all event handlers for streaming text, tools, prompts, and auto-restart.
787
+ * Delegates to SessionLifecycle.
639
788
  */
640
789
  startClaude(sessionId) {
641
- const session = this.sessions.get(sessionId);
642
- if (!session)
643
- return false;
644
- // Clear stopped flag on explicit start
645
- session._stoppedByUser = false;
646
- // Kill existing process if any
647
- if (session.claudeProcess) {
648
- session.claudeProcess.stop();
649
- }
650
- // Derive a session-scoped token instead of forwarding the master auth token.
651
- // This limits child process privileges to approve/deny for their own session only.
652
- const sessionToken = this._authToken
653
- ? deriveSessionToken(this._authToken, sessionId)
654
- : '';
655
- // Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
656
- // (current canonical name) are set to the same derived value for backward compatibility.
657
- const extraEnv = {
658
- CODEKIN_SESSION_ID: sessionId,
659
- CODEKIN_PORT: String(this._serverPort || PORT),
660
- CODEKIN_TOKEN: sessionToken,
661
- CODEKIN_AUTH_TOKEN: sessionToken,
662
- CODEKIN_SESSION_TYPE: session.source || 'manual',
663
- ...(session.permissionMode === 'dangerouslySkipPermissions' ? { CODEKIN_SKIP_PERMISSIONS: '1' } : {}),
664
- };
665
- // Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
666
- // even when the session's working directory differs from the project root
667
- // (e.g. worktrees, webhook workspaces). Note: this does NOT control
668
- // session storage path — Claude CLI uses the CWD for that.
669
- if (session.groupDir) {
670
- extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
671
- }
672
- else if (process.env.CLAUDE_PROJECT_DIR) {
673
- extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
674
- }
675
- // When claudeSessionId exists, the session has run before and a JSONL file
676
- // exists on disk. Use --resume (not --session-id) to continue it — --session-id
677
- // creates a *new* session and fails with "already in use" if the JSONL exists.
678
- const resume = !!session.claudeSessionId;
679
- // Build comprehensive allowedTools from session-level overrides + registry approvals
680
- const repoDir = session.groupDir ?? session.workingDir;
681
- const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
682
- const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
683
- const cp = new ClaudeProcess(session.workingDir, {
684
- sessionId: session.claudeSessionId || undefined,
685
- extraEnv,
686
- model: session.model,
687
- permissionMode: session.permissionMode,
688
- resume,
689
- allowedTools: mergedAllowedTools,
690
- });
691
- this.wireClaudeEvents(cp, session, sessionId);
692
- cp.start();
693
- session.claudeProcess = cp;
694
- this._globalBroadcast?.({ type: 'sessions_updated' });
695
- const startMsg = { type: 'claude_started', sessionId };
696
- this.addToHistory(session, startMsg);
697
- this.broadcast(session, startMsg);
698
- return true;
790
+ return this.sessionLifecycle.startClaude(sessionId);
699
791
  }
700
792
  /**
701
- * Wait for a session's Claude process to emit its system_init event,
702
- * indicating it is ready to accept input. Resolves immediately if the
703
- * session already has a claudeSessionId (process previously initialized).
704
- * 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.
705
795
  */
706
796
  waitForReady(sessionId, timeoutMs = 30_000) {
707
- const session = this.sessions.get(sessionId);
708
- if (!session?.claudeProcess)
709
- return Promise.resolve();
710
- // If the process already completed init in a prior turn, resolve immediately
711
- if (session.claudeSessionId)
712
- return Promise.resolve();
713
- return new Promise((resolve) => {
714
- const timer = setTimeout(() => {
715
- console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`);
716
- resolve();
717
- }, timeoutMs);
718
- session.claudeProcess.once('system_init', () => {
719
- clearTimeout(timer);
720
- resolve();
721
- });
722
- });
723
- }
724
- /**
725
- * Attach all ClaudeProcess event listeners for a session.
726
- * Extracted from startClaude() to keep that method focused on process setup.
727
- */
728
- wireClaudeEvents(cp, session, sessionId) {
729
- cp.on('system_init', (model) => this.onSystemInit(cp, session, model));
730
- cp.on('text', (text) => this.onTextEvent(session, sessionId, text));
731
- cp.on('thinking', (summary) => this.onThinkingEvent(session, summary));
732
- cp.on('tool_output', (content, isError) => this.onToolOutputEvent(session, content, isError));
733
- cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
734
- cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
735
- cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
736
- cp.on('planning_mode', (active) => {
737
- // Route EnterPlanMode through PlanManager for UI state tracking.
738
- // ExitPlanMode (active=false) is ignored here — the PreToolUse hook
739
- // is the enforcement gate, and it calls handleExitPlanModeApproval()
740
- // which transitions PlanManager to 'reviewing'.
741
- if (active) {
742
- session.planManager.onEnterPlanMode();
743
- }
744
- // ExitPlanMode stream event intentionally ignored — hook handles it.
745
- });
746
- cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
747
- cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
748
- cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
749
- cp.on('result', (result, isError) => {
750
- session.planManager.onTurnEnd();
751
- this.handleClaudeResult(session, sessionId, result, isError);
752
- });
753
- cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
754
- cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(session, sessionId, code, signal); });
797
+ return this.sessionLifecycle.waitForReady(sessionId, timeoutMs);
755
798
  }
756
799
  /** Broadcast a message and add it to the session's output history. */
757
800
  broadcastAndHistory(session, msg) {
@@ -801,89 +844,6 @@ export class SessionManager {
801
844
  onToolDoneEvent(session, toolName, summary) {
802
845
  this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
803
846
  }
804
- onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
805
- const promptMsg = {
806
- type: 'prompt',
807
- promptType,
808
- question,
809
- options,
810
- multiSelect,
811
- toolName,
812
- toolInput,
813
- requestId,
814
- ...(questions ? { questions } : {}),
815
- };
816
- if (requestId) {
817
- session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
818
- }
819
- this.broadcast(session, promptMsg);
820
- // Notify prompt listeners (orchestrator, child monitor, etc.)
821
- for (const listener of this._promptListeners) {
822
- try {
823
- listener(session.id, promptType, toolName, requestId);
824
- }
825
- catch { /* listener error */ }
826
- }
827
- }
828
- onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
829
- if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
830
- console.warn(`[control_request] Rejected invalid requestId: ${JSON.stringify(requestId)}`);
831
- return;
832
- }
833
- console.log(`[control_request] session=${sessionId} tool=${toolName} requestId=${requestId}`);
834
- if (this.resolveAutoApproval(session, toolName, toolInput) !== 'prompt') {
835
- console.log(`[control_request] auto-approved: ${toolName}`);
836
- cp.sendControlResponse(requestId, 'allow');
837
- return;
838
- }
839
- // Prevent double-gating: if a PreToolUse hook is already handling approval
840
- // for this tool, auto-approve the control_request to avoid duplicate entries.
841
- // Without this, both pendingToolApprovals and pendingControlRequests contain
842
- // entries for the same tool invocation, causing stale-entry races when the
843
- // orchestrator tries to respond via the REST API.
844
- for (const pending of session.pendingToolApprovals.values()) {
845
- if (pending.toolName === toolName) {
846
- console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
847
- cp.sendControlResponse(requestId, 'allow');
848
- return;
849
- }
850
- }
851
- const question = this.summarizeToolPermission(toolName, toolInput);
852
- const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
853
- const options = [
854
- { label: 'Allow', value: 'allow' },
855
- ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
856
- { label: 'Deny', value: 'deny' },
857
- ];
858
- const promptMsg = {
859
- type: 'prompt',
860
- promptType: 'permission',
861
- question,
862
- options,
863
- toolName,
864
- toolInput,
865
- requestId,
866
- };
867
- session.pendingControlRequests.set(requestId, { requestId, toolName, toolInput, promptMsg });
868
- if (session.clients.size > 0) {
869
- this.broadcast(session, promptMsg);
870
- }
871
- else {
872
- console.log(`[control_request] no clients connected, waiting for client to join: ${toolName}`);
873
- this._globalBroadcast?.({
874
- ...promptMsg,
875
- sessionId,
876
- sessionName: session.name,
877
- });
878
- }
879
- // Notify prompt listeners (orchestrator, child monitor, etc.)
880
- for (const listener of this._promptListeners) {
881
- try {
882
- listener(sessionId, 'permission', toolName, requestId);
883
- }
884
- catch { /* listener error */ }
885
- }
886
- }
887
847
  /**
888
848
  * Handle a Claude process 'result' event: update session state, apply API
889
849
  * retry logic for transient errors, broadcast result to clients, and trigger
@@ -933,15 +893,20 @@ export class SessionManager {
933
893
  handleApiRetry(session, sessionId, result) {
934
894
  if (!session._lastUserInput || !this.isRetryableApiError(result)) {
935
895
  session._apiRetryCount = 0;
896
+ session._apiRetryScheduled = false;
936
897
  return false;
937
898
  }
938
899
  // Skip retry if the original input is older than 60 seconds — context has likely moved on
939
900
  if (session._lastUserInputAt && Date.now() - session._lastUserInputAt > 60_000) {
940
901
  console.log(`[api-retry] skipping stale retry for session=${sessionId} (input age=${Math.round((Date.now() - session._lastUserInputAt) / 1000)}s)`);
941
902
  session._apiRetryCount = 0;
903
+ session._apiRetryScheduled = false;
942
904
  return false;
943
905
  }
944
906
  if (session._apiRetryCount < MAX_API_RETRIES) {
907
+ // Prevent duplicate scheduling from concurrent error paths
908
+ if (session._apiRetryScheduled)
909
+ return true;
945
910
  session._apiRetryCount++;
946
911
  const attempt = session._apiRetryCount;
947
912
  const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
@@ -955,8 +920,10 @@ export class SessionManager {
955
920
  console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
956
921
  if (session._apiRetryTimer)
957
922
  clearTimeout(session._apiRetryTimer);
923
+ session._apiRetryScheduled = true;
958
924
  session._apiRetryTimer = setTimeout(() => {
959
925
  session._apiRetryTimer = undefined;
926
+ session._apiRetryScheduled = false;
960
927
  if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
961
928
  return;
962
929
  console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
@@ -973,6 +940,7 @@ export class SessionManager {
973
940
  this.addToHistory(session, exhaustedMsg);
974
941
  this.broadcast(session, exhaustedMsg);
975
942
  session._apiRetryCount = 0;
943
+ session._apiRetryScheduled = false;
976
944
  return false;
977
945
  }
978
946
  /**
@@ -981,6 +949,7 @@ export class SessionManager {
981
949
  */
982
950
  finalizeResult(session, sessionId, result, isError) {
983
951
  session._apiRetryCount = 0;
952
+ session._apiRetryScheduled = false;
984
953
  session._lastUserInput = undefined;
985
954
  session._lastUserInputAt = undefined;
986
955
  if (isError) {
@@ -1014,88 +983,9 @@ export class SessionManager {
1014
983
  void this.sessionNaming.executeSessionNaming(sessionId);
1015
984
  }
1016
985
  }
1017
- /**
1018
- * Handle a Claude process 'exit' event: clean up state, notify exit listeners,
1019
- * and either auto-restart (within limits) or broadcast the final exit message.
1020
- *
1021
- * Uses evaluateRestart() for the restart decision, keeping this method focused
1022
- * on state updates, listener notification, and message broadcasting.
1023
- */
1024
- handleClaudeExit(session, sessionId, code, signal) {
1025
- session.claudeProcess = null;
1026
- session.isProcessing = false;
1027
- session.planManager.reset();
1028
- this._globalBroadcast?.({ type: 'sessions_updated' });
1029
- const action = evaluateRestart({
1030
- restartCount: session.restartCount,
1031
- lastRestartAt: session.lastRestartAt,
1032
- stoppedByUser: session._stoppedByUser,
1033
- });
1034
- if (action.kind === 'stopped_by_user') {
1035
- for (const listener of this._exitListeners) {
1036
- try {
1037
- listener(sessionId, code, signal, false);
1038
- }
1039
- catch { /* listener error */ }
1040
- }
1041
- const msg = { type: 'system_message', subtype: 'exit', text: `Claude process exited: code=${code}, signal=${signal}` };
1042
- this.addToHistory(session, msg);
1043
- this.broadcast(session, msg);
1044
- this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
1045
- return;
1046
- }
1047
- if (action.kind === 'restart') {
1048
- session.restartCount = action.updatedCount;
1049
- session.lastRestartAt = action.updatedLastRestartAt;
1050
- for (const listener of this._exitListeners) {
1051
- try {
1052
- listener(sessionId, code, signal, true);
1053
- }
1054
- catch { /* listener error */ }
1055
- }
1056
- const msg = {
1057
- type: 'system_message',
1058
- subtype: 'restart',
1059
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
1060
- };
1061
- this.addToHistory(session, msg);
1062
- this.broadcast(session, msg);
1063
- setTimeout(() => {
1064
- // Verify session still exists and hasn't been stopped
1065
- if (!this.sessions.has(sessionId) || session._stoppedByUser)
1066
- return;
1067
- // startClaude uses --resume when claudeSessionId exists, so the CLI
1068
- // picks up the full conversation history from the JSONL automatically.
1069
- this.startClaude(sessionId);
1070
- // Fallback: if claudeSessionId was already null (fresh session that
1071
- // crashed before system_init), inject a context summary so the new
1072
- // session has some awareness of prior conversation.
1073
- if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
1074
- session.claudeProcess.once('system_init', () => {
1075
- const context = this.buildSessionContext(session);
1076
- if (context) {
1077
- 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.]');
1078
- }
1079
- });
1080
- }
1081
- }, action.delayMs);
1082
- return;
1083
- }
1084
- // action.kind === 'exhausted'
1085
- for (const listener of this._exitListeners) {
1086
- try {
1087
- listener(sessionId, code, signal, false);
1088
- }
1089
- catch { /* listener error */ }
1090
- }
1091
- const msg = {
1092
- type: 'system_message',
1093
- subtype: 'error',
1094
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
1095
- };
1096
- this.addToHistory(session, msg);
1097
- this.broadcast(session, msg);
1098
- 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);
1099
989
  }
1100
990
  /**
1101
991
  * Send user input to a session's Claude process.
@@ -1151,436 +1041,19 @@ export class SessionManager {
1151
1041
  session.claudeProcess?.sendMessage(data);
1152
1042
  }
1153
1043
  /**
1154
- * Route a user's prompt response to the correct handler: pending tool approval
1155
- * (from PermissionRequest hook), pending control request (from control_request
1156
- * fallback path), or plain message fallback.
1044
+ * Route a user's prompt response to the correct handler.
1045
+ * Delegates to PromptRouter.
1157
1046
  */
1158
1047
  sendPromptResponse(sessionId, value, requestId) {
1159
- const session = this.sessions.get(sessionId);
1160
- if (!session)
1161
- return;
1162
- session._lastActivityAt = Date.now();
1163
- // ExitPlanMode approvals are handled through the normal pendingToolApprovals
1164
- // path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
1165
- // Check for pending tool approval from PreToolUse hook
1166
- if (!requestId) {
1167
- const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
1168
- if (totalPending === 1) {
1169
- // Exactly one pending prompt — safe to infer the target
1170
- const soleApproval = session.pendingToolApprovals.size === 1
1171
- ? session.pendingToolApprovals.values().next().value
1172
- : undefined;
1173
- if (soleApproval) {
1174
- console.warn(`[prompt_response] no requestId, routing to sole pending tool approval: ${soleApproval.toolName}`);
1175
- this.resolveToolApproval(session, soleApproval, value);
1176
- return;
1177
- }
1178
- const soleControl = session.pendingControlRequests.size === 1
1179
- ? session.pendingControlRequests.values().next().value
1180
- : undefined;
1181
- if (soleControl) {
1182
- console.warn(`[prompt_response] no requestId, routing to sole pending control request: ${soleControl.toolName}`);
1183
- requestId = soleControl.requestId;
1184
- }
1185
- }
1186
- else if (totalPending > 1) {
1187
- console.warn(`[prompt_response] no requestId with ${totalPending} pending prompts — rejecting to prevent misrouted response`);
1188
- this.broadcast(session, {
1189
- type: 'system_message',
1190
- subtype: 'error',
1191
- text: 'Prompt response could not be routed: multiple prompts pending. Please refresh and try again.',
1192
- });
1193
- return;
1194
- }
1195
- else {
1196
- console.warn(`[prompt_response] no requestId, no pending prompts — forwarding as user message`);
1197
- }
1198
- }
1199
- const approval = requestId ? session.pendingToolApprovals.get(requestId) : undefined;
1200
- if (approval) {
1201
- this.resolveToolApproval(session, approval, value);
1202
- return;
1203
- }
1204
- if (!session.claudeProcess?.isAlive())
1205
- return;
1206
- // Find matching pending control request
1207
- const pending = requestId ? session.pendingControlRequests.get(requestId) : undefined;
1208
- if (pending) {
1209
- session.pendingControlRequests.delete(pending.requestId);
1210
- // Dismiss prompt on all other clients viewing this session
1211
- this.broadcast(session, { type: 'prompt_dismiss', requestId: pending.requestId });
1212
- if (pending.toolName === 'AskUserQuestion') {
1213
- this.handleAskUserQuestion(session, pending, value);
1214
- }
1215
- else {
1216
- this.sendControlResponseForRequest(session, pending, value);
1217
- }
1218
- }
1219
- else {
1220
- // Fallback: no pending control request, send as plain user message
1221
- const answer = Array.isArray(value) ? value.join(', ') : value;
1222
- session.claudeProcess.sendMessage(answer);
1223
- }
1224
- }
1225
- /** Decode the allow/deny/always/pattern intent from a prompt response value. */
1226
- decodeApprovalValue(value) {
1227
- const first = Array.isArray(value) ? value[0] : value;
1228
- return {
1229
- isDeny: first === 'deny',
1230
- isAlwaysAllow: first === 'always_allow',
1231
- isApprovePattern: first === 'approve_pattern',
1232
- };
1233
- }
1234
- /** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
1235
- resolveToolApproval(session, approval, value) {
1236
- // AskUserQuestion: the value IS the user's answer, not a permission decision
1237
- if (approval.toolName === 'AskUserQuestion') {
1238
- const answer = Array.isArray(value) ? value.join(', ') : value;
1239
- console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
1240
- approval.resolve({ allow: true, always: false, answer });
1241
- session.pendingToolApprovals.delete(approval.requestId);
1242
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1243
- return;
1244
- }
1245
- // ExitPlanMode: route through PlanManager for state tracking.
1246
- // The hook will convert allow→deny-with-approval-message (CLI workaround).
1247
- if (approval.toolName === 'ExitPlanMode') {
1248
- const first = Array.isArray(value) ? value[0] : value;
1249
- const isDeny = first === 'deny';
1250
- if (isDeny) {
1251
- // Extract feedback text if present (value may be ['deny', 'feedback text'])
1252
- const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
1253
- const reason = session.planManager.deny(approval.requestId, feedback);
1254
- console.log(`[plan-approval] denied: ${reason}`);
1255
- approval.resolve({ allow: false, always: false });
1256
- }
1257
- else {
1258
- session.planManager.approve(approval.requestId);
1259
- console.log(`[plan-approval] approved`);
1260
- approval.resolve({ allow: true, always: false });
1261
- }
1262
- session.pendingToolApprovals.delete(approval.requestId);
1263
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1264
- return;
1265
- }
1266
- const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1267
- if (isAlwaysAllow && !isDeny) {
1268
- this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
1269
- }
1270
- if (isApprovePattern && !isDeny) {
1271
- this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
1272
- }
1273
- console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
1274
- approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
1275
- session.pendingToolApprovals.delete(approval.requestId);
1276
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1277
- }
1278
- /**
1279
- * Send an AskUserQuestion control response, mapping the user's answer(s) into
1280
- * the structured answers map the tool expects.
1281
- */
1282
- handleAskUserQuestion(session, pending, value) {
1283
- const questions = pending.toolInput?.questions;
1284
- const updatedInput = { ...pending.toolInput };
1285
- let answers = {};
1286
- if (typeof value === 'string') {
1287
- // Try parsing as JSON answers map (multi-question flow)
1288
- try {
1289
- const parsed = JSON.parse(value);
1290
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1291
- answers = parsed;
1292
- }
1293
- else if (Array.isArray(questions) && questions.length > 0) {
1294
- answers[questions[0].question] = value;
1295
- }
1296
- }
1297
- catch {
1298
- // Plain string answer — map to first question
1299
- if (Array.isArray(questions) && questions.length > 0) {
1300
- answers[questions[0].question] = value;
1301
- }
1302
- }
1303
- }
1304
- else if (Array.isArray(value) && Array.isArray(questions) && questions.length > 0) {
1305
- // Array of answers — map to first question (multi-select single question)
1306
- answers[questions[0].question] = value.join(', ');
1307
- }
1308
- updatedInput.answers = answers;
1309
- session.claudeProcess.sendControlResponse(pending.requestId, 'allow', updatedInput);
1310
- }
1311
- /** Send a permission control response (allow/always_allow/approve_pattern/deny). */
1312
- sendControlResponseForRequest(session, pending, value) {
1313
- const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1314
- if (isAlwaysAllow) {
1315
- this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
1316
- }
1317
- if (isApprovePattern) {
1318
- this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
1319
- }
1320
- const behavior = isDeny ? 'deny' : 'allow';
1321
- session.claudeProcess.sendControlResponse(pending.requestId, behavior);
1048
+ this.promptRouter.sendPromptResponse(sessionId, value, requestId);
1322
1049
  }
1323
1050
  /**
1324
1051
  * Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
1325
1052
  * and returns a Promise that resolves when the user approves/denies.
1053
+ * Delegates to PromptRouter.
1326
1054
  */
1327
1055
  requestToolApproval(sessionId, toolName, toolInput) {
1328
- const session = this.sessions.get(sessionId);
1329
- if (!session) {
1330
- console.log(`[tool-approval] session not found: ${sessionId}`);
1331
- return Promise.resolve({ allow: false, always: false });
1332
- }
1333
- const autoResult = this.resolveAutoApproval(session, toolName, toolInput);
1334
- if (autoResult === 'registry') {
1335
- console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
1336
- return Promise.resolve({ allow: true, always: true });
1337
- }
1338
- if (autoResult === 'session') {
1339
- console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
1340
- return Promise.resolve({ allow: true, always: false });
1341
- }
1342
- if (autoResult === 'headless') {
1343
- console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
1344
- return Promise.resolve({ allow: true, always: false });
1345
- }
1346
- console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
1347
- // ExitPlanMode: route through PlanManager state machine for plan-specific
1348
- // approval UI. The hook blocks until we resolve the promise.
1349
- if (toolName === 'ExitPlanMode') {
1350
- return this.handleExitPlanModeApproval(session, sessionId);
1351
- }
1352
- // Prevent double-gating: if a control_request already created a pending
1353
- // entry for this tool, auto-approve the control_request and let the hook
1354
- // take over as the sole approval gate. This is the reverse of the check
1355
- // in onControlRequestEvent (which handles hook-first ordering).
1356
- for (const [reqId, pending] of session.pendingControlRequests) {
1357
- if (pending.toolName === toolName) {
1358
- console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
1359
- session.claudeProcess?.sendControlResponse(reqId, 'allow');
1360
- session.pendingControlRequests.delete(reqId);
1361
- this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
1362
- break;
1363
- }
1364
- }
1365
- // AskUserQuestion: show a question prompt and collect the answer text,
1366
- // rather than a permission prompt with Allow/Deny buttons.
1367
- const isQuestion = toolName === 'AskUserQuestion';
1368
- return new Promise((resolve) => {
1369
- // Holder lets wrappedResolve reference the timeout before it's assigned
1370
- const timer = { id: null };
1371
- const wrappedResolve = (result) => {
1372
- if (timer.id)
1373
- clearTimeout(timer.id);
1374
- resolve(result);
1375
- };
1376
- const approvalRequestId = randomUUID();
1377
- // Timeout to prevent leaked promises if client disconnects after prompt is sent
1378
- timer.id = setTimeout(() => {
1379
- if (session.pendingToolApprovals.has(approvalRequestId)) {
1380
- console.log(`[tool-approval] timed out for ${toolName}`);
1381
- session.pendingToolApprovals.delete(approvalRequestId);
1382
- // Dismiss the stale prompt in all clients so they don't inject
1383
- // "allow"/"deny" as plain text after the timeout
1384
- this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
1385
- resolve({ allow: false, always: false });
1386
- }
1387
- }, isQuestion ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions & agent children, 1 min for interactive
1388
- let promptMsg;
1389
- if (isQuestion) {
1390
- // AskUserQuestion: extract structured questions from toolInput.questions
1391
- // and pass them through so PromptButtons can render the multi-question flow.
1392
- const rawQuestions = toolInput.questions;
1393
- const structuredQuestions = Array.isArray(rawQuestions)
1394
- ? rawQuestions.map(q => ({
1395
- question: q.question,
1396
- header: q.header,
1397
- multiSelect: q.multiSelect ?? false,
1398
- options: (q.options || []).map((opt) => ({
1399
- label: opt.label,
1400
- value: opt.value ?? opt.label,
1401
- description: opt.description,
1402
- })),
1403
- }))
1404
- : undefined;
1405
- const firstQ = structuredQuestions?.[0];
1406
- promptMsg = {
1407
- type: 'prompt',
1408
- promptType: 'question',
1409
- question: firstQ?.question || 'Answer the question',
1410
- options: firstQ?.options || [],
1411
- multiSelect: firstQ?.multiSelect,
1412
- toolName,
1413
- toolInput,
1414
- requestId: approvalRequestId,
1415
- ...(structuredQuestions ? { questions: structuredQuestions } : {}),
1416
- };
1417
- }
1418
- else {
1419
- const question = this.summarizeToolPermission(toolName, toolInput);
1420
- const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
1421
- const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
1422
- const options = [
1423
- { label: 'Allow', value: 'allow' },
1424
- ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
1425
- { label: 'Deny', value: 'deny' },
1426
- ];
1427
- promptMsg = {
1428
- type: 'prompt',
1429
- promptType: 'permission',
1430
- question,
1431
- options,
1432
- toolName,
1433
- toolInput,
1434
- requestId: approvalRequestId,
1435
- ...(approvePattern ? { approvePattern } : {}),
1436
- };
1437
- }
1438
- session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
1439
- if (session.clients.size > 0) {
1440
- this.broadcast(session, promptMsg);
1441
- }
1442
- else {
1443
- // No clients connected — DON'T auto-deny. Instead, wait for a client
1444
- // to join this session (the prompt will be re-broadcast in join()).
1445
- // Send a global notification so the user sees a waiting indicator.
1446
- console.log(`[tool-approval] no clients connected, waiting for client to join (timeout 60s): ${toolName}`);
1447
- this._globalBroadcast?.({
1448
- ...promptMsg,
1449
- sessionId,
1450
- sessionName: session.name,
1451
- });
1452
- }
1453
- // Notify prompt listeners (orchestrator, child monitor, etc.)
1454
- for (const listener of this._promptListeners) {
1455
- try {
1456
- listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
1457
- }
1458
- catch { /* listener error */ }
1459
- }
1460
- });
1461
- }
1462
- /**
1463
- * Handle ExitPlanMode approval through PlanManager.
1464
- * Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
1465
- * until the user responds. On approve, returns allow:true (the hook will use
1466
- * the deny-with-approval-message workaround). On deny, returns allow:false.
1467
- */
1468
- handleExitPlanModeApproval(session, sessionId) {
1469
- const reviewId = session.planManager.onExitPlanModeRequested();
1470
- if (!reviewId) {
1471
- // Not in planning state — fall through to allow (CLI handles natively)
1472
- console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
1473
- return Promise.resolve({ allow: true, always: false });
1474
- }
1475
- return new Promise((resolve) => {
1476
- const timer = { id: null };
1477
- const wrappedResolve = (result) => {
1478
- if (timer.id)
1479
- clearTimeout(timer.id);
1480
- resolve(result);
1481
- };
1482
- // Timeout: auto-deny after 5 minutes to prevent leaked promises
1483
- timer.id = setTimeout(() => {
1484
- if (session.pendingToolApprovals.has(reviewId)) {
1485
- console.log(`[plan-approval] timed out, auto-denying`);
1486
- session.pendingToolApprovals.delete(reviewId);
1487
- session.planManager.deny(reviewId);
1488
- this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
1489
- resolve({ allow: false, always: false });
1490
- }
1491
- }, 300_000);
1492
- const promptMsg = {
1493
- type: 'prompt',
1494
- promptType: 'permission',
1495
- question: 'Approve plan and start implementation?',
1496
- options: [
1497
- { label: 'Approve', value: 'allow' },
1498
- { label: 'Reject', value: 'deny' },
1499
- ],
1500
- toolName: 'ExitPlanMode',
1501
- requestId: reviewId,
1502
- };
1503
- session.pendingToolApprovals.set(reviewId, {
1504
- resolve: wrappedResolve,
1505
- toolName: 'ExitPlanMode',
1506
- toolInput: {},
1507
- requestId: reviewId,
1508
- promptMsg,
1509
- });
1510
- this.broadcast(session, promptMsg);
1511
- if (session.clients.size === 0) {
1512
- this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
1513
- }
1514
- for (const listener of this._promptListeners) {
1515
- try {
1516
- listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
1517
- }
1518
- catch { /* listener error */ }
1519
- }
1520
- });
1521
- }
1522
- /**
1523
- * Check if a tool invocation can be auto-approved without prompting the user.
1524
- * Returns 'registry' if matched by auto-approval rules, 'session' if matched
1525
- * by the session's allowedTools list, 'headless' if the session has no clients
1526
- * and is a non-interactive source, or 'prompt' if the user needs to decide.
1527
- */
1528
- resolveAutoApproval(session, toolName, toolInput) {
1529
- if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
1530
- return 'registry';
1531
- }
1532
- if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
1533
- return 'session';
1534
- }
1535
- if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'orchestrator')) {
1536
- return 'headless';
1537
- }
1538
- return 'prompt';
1539
- }
1540
- /**
1541
- * Check if a tool invocation matches any of the session's allowedTools patterns.
1542
- * Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
1543
- * Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
1544
- */
1545
- matchesAllowedTools(allowedTools, toolName, toolInput) {
1546
- for (const pattern of allowedTools) {
1547
- // Simple tool name match: 'WebFetch', 'Read', etc.
1548
- if (pattern === toolName)
1549
- return true;
1550
- // Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
1551
- const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
1552
- if (match) {
1553
- const [, patternTool, prefix] = match;
1554
- if (patternTool !== toolName)
1555
- continue;
1556
- // For Bash, check command prefix
1557
- if (toolName === 'Bash') {
1558
- const cmd = String(toolInput.command || '').trimStart();
1559
- if (cmd === prefix || cmd.startsWith(prefix + ' '))
1560
- return true;
1561
- }
1562
- }
1563
- }
1564
- return false;
1565
- }
1566
- /** Build a human-readable prompt string for a tool permission dialog. */
1567
- summarizeToolPermission(toolName, toolInput) {
1568
- switch (toolName) {
1569
- case 'Bash': {
1570
- const cmd = String(toolInput.command || '');
1571
- const firstLine = cmd.split('\n')[0];
1572
- const display = firstLine.length < cmd.length ? `${firstLine}...` : cmd;
1573
- return `Allow Bash? \`$ ${display}\``;
1574
- }
1575
- case 'Task':
1576
- return `Allow Task? ${String(toolInput.description || toolName)}`;
1577
- case 'Read': {
1578
- const filePath = String(toolInput.file_path || '');
1579
- return `Allow Read? \`${filePath}\``;
1580
- }
1581
- default:
1582
- return `Allow ${toolName}?`;
1583
- }
1056
+ return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
1584
1057
  }
1585
1058
  /** Update the model for a session and restart Claude with the new model. */
1586
1059
  setModel(sessionId, model) {
@@ -1589,15 +1062,16 @@ export class SessionManager {
1589
1062
  return false;
1590
1063
  session.model = model || undefined;
1591
1064
  this.persistToDiskDebounced();
1592
- // 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.
1593
1068
  if (session.claudeProcess?.isAlive()) {
1594
- this.stopClaude(sessionId);
1595
- session._stoppedByUser = false;
1596
- setTimeout(() => {
1597
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1069
+ void this.stopClaudeAndWait(sessionId).then(() => {
1070
+ if (this.sessions.has(sessionId)) {
1071
+ session._stoppedByUser = false;
1598
1072
  this.startClaude(sessionId);
1599
1073
  }
1600
- }, 500);
1074
+ });
1601
1075
  }
1602
1076
  return true;
1603
1077
  }
@@ -1618,49 +1092,29 @@ export class SessionManager {
1618
1092
  const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
1619
1093
  this.addToHistory(session, sysMsg);
1620
1094
  this.broadcast(session, sysMsg);
1621
- // 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.
1622
1098
  if (session.claudeProcess?.isAlive()) {
1623
- this.stopClaude(sessionId);
1624
- session._stoppedByUser = false;
1625
- setTimeout(() => {
1626
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1099
+ void this.stopClaudeAndWait(sessionId).then(() => {
1100
+ if (this.sessions.has(sessionId)) {
1101
+ session._stoppedByUser = false;
1627
1102
  this.startClaude(sessionId);
1628
1103
  }
1629
- }, 500);
1104
+ });
1630
1105
  }
1631
1106
  return true;
1632
1107
  }
1108
+ /** Stop the Claude process for a session. Delegates to SessionLifecycle. */
1633
1109
  stopClaude(sessionId) {
1634
- const session = this.sessions.get(sessionId);
1635
- if (session?.claudeProcess) {
1636
- session._stoppedByUser = true;
1637
- if (session._apiRetryTimer)
1638
- clearTimeout(session._apiRetryTimer);
1639
- session.claudeProcess.removeAllListeners();
1640
- session.claudeProcess.stop();
1641
- session.claudeProcess = null;
1642
- this.broadcast(session, { type: 'claude_stopped' });
1643
- }
1110
+ this.sessionLifecycle.stopClaude(sessionId);
1644
1111
  }
1645
1112
  /**
1646
1113
  * Stop the Claude process and wait for it to fully exit before resolving.
1647
- * This prevents race conditions when restarting with the same session ID
1648
- * (e.g. during mid-session worktree migration).
1114
+ * Delegates to SessionLifecycle.
1649
1115
  */
1650
1116
  async stopClaudeAndWait(sessionId) {
1651
- const session = this.sessions.get(sessionId);
1652
- if (!session?.claudeProcess)
1653
- return;
1654
- const cp = session.claudeProcess;
1655
- session._stoppedByUser = true;
1656
- if (session._apiRetryTimer)
1657
- clearTimeout(session._apiRetryTimer);
1658
- cp.removeAllListeners();
1659
- cp.stop();
1660
- session.claudeProcess = null;
1661
- this.broadcast(session, { type: 'claude_stopped' });
1662
- // Wait for the underlying OS process to fully exit
1663
- await cp.waitForExit();
1117
+ return this.sessionLifecycle.stopClaudeAndWait(sessionId);
1664
1118
  }
1665
1119
  // ---------------------------------------------------------------------------
1666
1120
  // Helpers