codekin 0.5.4 → 0.6.0

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 (88) hide show
  1. package/README.md +1 -0
  2. package/dist/assets/index-BvKzbtKg.css +1 -0
  3. package/dist/assets/index-D59Xr9pK.js +182 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/server/dist/approval-manager.js +2 -3
  7. package/server/dist/approval-manager.js.map +1 -1
  8. package/server/dist/claude-process.d.ts +17 -5
  9. package/server/dist/claude-process.js +56 -66
  10. package/server/dist/claude-process.js.map +1 -1
  11. package/server/dist/coding-process.d.ts +83 -0
  12. package/server/dist/coding-process.js +32 -0
  13. package/server/dist/coding-process.js.map +1 -0
  14. package/server/dist/commit-event-handler.js +1 -0
  15. package/server/dist/commit-event-handler.js.map +1 -1
  16. package/server/dist/config.js +2 -1
  17. package/server/dist/config.js.map +1 -1
  18. package/server/dist/docs-routes.js +22 -1
  19. package/server/dist/docs-routes.js.map +1 -1
  20. package/server/dist/native-permissions.js +3 -2
  21. package/server/dist/native-permissions.js.map +1 -1
  22. package/server/dist/opencode-process.d.ts +104 -0
  23. package/server/dist/opencode-process.js +657 -0
  24. package/server/dist/opencode-process.js.map +1 -0
  25. package/server/dist/orchestrator-children.d.ts +9 -0
  26. package/server/dist/orchestrator-children.js +50 -7
  27. package/server/dist/orchestrator-children.js.map +1 -1
  28. package/server/dist/orchestrator-manager.js +17 -0
  29. package/server/dist/orchestrator-manager.js.map +1 -1
  30. package/server/dist/orchestrator-reports.d.ts +0 -4
  31. package/server/dist/orchestrator-reports.js +6 -12
  32. package/server/dist/orchestrator-reports.js.map +1 -1
  33. package/server/dist/orchestrator-routes.js +8 -1
  34. package/server/dist/orchestrator-routes.js.map +1 -1
  35. package/server/dist/prompt-router.d.ts +95 -0
  36. package/server/dist/prompt-router.js +589 -0
  37. package/server/dist/prompt-router.js.map +1 -0
  38. package/server/dist/session-lifecycle.d.ts +73 -0
  39. package/server/dist/session-lifecycle.js +408 -0
  40. package/server/dist/session-lifecycle.js.map +1 -0
  41. package/server/dist/session-manager.d.ts +42 -60
  42. package/server/dist/session-manager.js +310 -835
  43. package/server/dist/session-manager.js.map +1 -1
  44. package/server/dist/session-persistence.d.ts +1 -0
  45. package/server/dist/session-persistence.js +24 -4
  46. package/server/dist/session-persistence.js.map +1 -1
  47. package/server/dist/session-routes.js +15 -1
  48. package/server/dist/session-routes.js.map +1 -1
  49. package/server/dist/stepflow-handler.d.ts +2 -2
  50. package/server/dist/stepflow-handler.js +4 -4
  51. package/server/dist/stepflow-handler.js.map +1 -1
  52. package/server/dist/tool-labels.d.ts +8 -0
  53. package/server/dist/tool-labels.js +51 -0
  54. package/server/dist/tool-labels.js.map +1 -0
  55. package/server/dist/tsconfig.tsbuildinfo +1 -1
  56. package/server/dist/types.d.ts +27 -8
  57. package/server/dist/types.js +4 -1
  58. package/server/dist/types.js.map +1 -1
  59. package/server/dist/webhook-dedup.d.ts +11 -0
  60. package/server/dist/webhook-dedup.js +23 -0
  61. package/server/dist/webhook-dedup.js.map +1 -1
  62. package/server/dist/webhook-handler.d.ts +20 -4
  63. package/server/dist/webhook-handler.js +256 -20
  64. package/server/dist/webhook-handler.js.map +1 -1
  65. package/server/dist/webhook-pr-cache.d.ts +57 -0
  66. package/server/dist/webhook-pr-cache.js +95 -0
  67. package/server/dist/webhook-pr-cache.js.map +1 -0
  68. package/server/dist/webhook-pr-github.d.ts +68 -0
  69. package/server/dist/webhook-pr-github.js +202 -0
  70. package/server/dist/webhook-pr-github.js.map +1 -0
  71. package/server/dist/webhook-pr-prompt.d.ts +27 -0
  72. package/server/dist/webhook-pr-prompt.js +251 -0
  73. package/server/dist/webhook-pr-prompt.js.map +1 -0
  74. package/server/dist/webhook-types.d.ts +70 -1
  75. package/server/dist/webhook-workspace.js +20 -1
  76. package/server/dist/webhook-workspace.js.map +1 -1
  77. package/server/dist/workflow-config.d.ts +2 -0
  78. package/server/dist/workflow-config.js.map +1 -1
  79. package/server/dist/workflow-loader.js +3 -0
  80. package/server/dist/workflow-loader.js.map +1 -1
  81. package/server/dist/workflow-routes.js +6 -2
  82. package/server/dist/workflow-routes.js.map +1 -1
  83. package/server/dist/ws-message-handler.js +24 -4
  84. package/server/dist/ws-message-handler.js.map +1 -1
  85. package/server/dist/ws-server.js +10 -2
  86. package/server/dist/ws-server.js.map +1 -1
  87. package/dist/assets/index-COGLICp9.js +0 -178
  88. package/dist/assets/index-CjEQkT2b.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;
@@ -62,6 +62,12 @@ const API_RETRY_PATTERNS = [
62
62
  /502/,
63
63
  /503/,
64
64
  ];
65
+ /** Sources that represent headless (non-interactive) sessions managed by their own lifecycles. */
66
+ const HEADLESS_SOURCES = new Set(['webhook', 'workflow', 'stepflow', 'agent', 'orchestrator']);
67
+ /** Check whether a session is headless (webhook, workflow, stepflow, agent, orchestrator). */
68
+ function isHeadlessSession(session) {
69
+ return HEADLESS_SOURCES.has(session.source ?? '');
70
+ }
65
71
  export class SessionManager {
66
72
  /** All active (non-archived) sessions, keyed by session UUID. */
67
73
  sessions = new Map();
@@ -89,12 +95,51 @@ export class SessionManager {
89
95
  sessionPersistence;
90
96
  /** Delegated diff operations (git diff, discard changes). */
91
97
  diffManager;
98
+ /** Delegated prompt routing and tool approval logic. */
99
+ promptRouter;
100
+ /** Delegated Claude process lifecycle (start, stop, restart, event wiring). */
101
+ sessionLifecycle;
92
102
  /** Interval handle for the idle session reaper. */
93
103
  _idleReaperInterval = null;
94
104
  constructor() {
95
105
  this.archive = new SessionArchive();
96
106
  this._approvalManager = new ApprovalManager();
97
107
  this.diffManager = new DiffManager();
108
+ this.promptRouter = new PromptRouter({
109
+ getSession: (id) => this.sessions.get(id),
110
+ allSessions: () => this.sessions.values(),
111
+ broadcast: (session, msg) => this.broadcast(session, msg),
112
+ addToHistory: (session, msg) => this.addToHistory(session, msg),
113
+ globalBroadcast: (msg) => this._globalBroadcast?.(msg),
114
+ approvalManager: this._approvalManager,
115
+ promptListeners: this._promptListeners,
116
+ });
117
+ // Use a local ref so the getter closures capture `this` (the SessionManager instance)
118
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
119
+ const self = this;
120
+ this.sessionLifecycle = new SessionLifecycle({
121
+ getSession: (id) => this.sessions.get(id),
122
+ hasSession: (id) => this.sessions.has(id),
123
+ broadcast: (session, msg) => this.broadcast(session, msg),
124
+ addToHistory: (session, msg) => this.addToHistory(session, msg),
125
+ broadcastAndHistory: (session, msg) => this.broadcastAndHistory(session, msg),
126
+ persistToDisk: () => this.persistToDisk(),
127
+ get globalBroadcast() { return self._globalBroadcast; },
128
+ get authToken() { return self._authToken; },
129
+ get serverPort() { return self._serverPort; },
130
+ approvalManager: this._approvalManager,
131
+ promptRouter: this.promptRouter,
132
+ exitListeners: this._exitListeners,
133
+ onSystemInit: (cp, session, model) => this.onSystemInit(cp, session, model),
134
+ onTextEvent: (session, sessionId, text) => this.onTextEvent(session, sessionId, text),
135
+ onThinkingEvent: (session, summary) => this.onThinkingEvent(session, summary),
136
+ onToolOutputEvent: (session, content, isError) => this.onToolOutputEvent(session, content, isError),
137
+ onImageEvent: (session, base64, mediaType) => this.onImageEvent(session, base64, mediaType),
138
+ onToolActiveEvent: (session, toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput),
139
+ onToolDoneEvent: (session, toolName, summary) => this.onToolDoneEvent(session, toolName, summary),
140
+ handleClaudeResult: (session, sessionId, result, isError) => this.handleClaudeResult(session, sessionId, result, isError),
141
+ buildSessionContext: (session) => this.buildSessionContext(session),
142
+ });
98
143
  this.sessionPersistence = new SessionPersistence(this.sessions);
99
144
  this.sessionNaming = new SessionNaming({
100
145
  getSession: (id) => this.sessions.get(id),
@@ -120,7 +165,7 @@ export class SessionManager {
120
165
  const now = Date.now();
121
166
  for (const session of this.sessions.values()) {
122
167
  // Skip headless sessions — they are managed by their own lifecycles
123
- if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'agent' || session.source === 'orchestrator')
168
+ if (isHeadlessSession(session))
124
169
  continue;
125
170
  // Skip sessions with connected clients or no running process
126
171
  if (session.clients.size > 0 || !session.claudeProcess?.isAlive())
@@ -132,6 +177,10 @@ export class SessionManager {
132
177
  if (idleMs > IDLE_SESSION_TIMEOUT_MS) {
133
178
  console.log(`[idle-reaper] stopping idle session=${session.id} name="${session.name}" idle=${Math.round(idleMs / 60_000)}min`);
134
179
  session._stoppedByUser = true; // prevent auto-restart
180
+ if (session._restartTimer) {
181
+ clearTimeout(session._restartTimer);
182
+ session._restartTimer = undefined;
183
+ }
135
184
  session.claudeProcess.removeAllListeners();
136
185
  session.claudeProcess.stop();
137
186
  session.claudeProcess = null;
@@ -143,10 +192,10 @@ export class SessionManager {
143
192
  }
144
193
  }
145
194
  // Prune stale sessions: no process, no clients, older than STALE_SESSION_AGE_MS
146
- // Agent and orchestrator sessions are exempt — they are long-lived by design.
195
+ // Headless sessions are exempt from stale pruning — they are long-lived by design.
147
196
  const staleIds = [];
148
197
  for (const session of this.sessions.values()) {
149
- if (session.source === 'agent' || session.source === 'orchestrator')
198
+ if (isHeadlessSession(session))
150
199
  continue;
151
200
  if (session.claudeProcess?.isAlive())
152
201
  continue;
@@ -203,9 +252,11 @@ export class SessionManager {
203
252
  groupDir: options?.groupDir,
204
253
  created: new Date().toISOString(),
205
254
  source: options?.source ?? 'manual',
255
+ provider: options?.provider ?? 'claude',
206
256
  model: options?.model,
207
257
  permissionMode: options?.permissionMode,
208
258
  allowedTools: options?.allowedTools,
259
+ addDirs: options?.addDirs,
209
260
  claudeProcess: null,
210
261
  clients: new Set(),
211
262
  outputHistory: [],
@@ -214,7 +265,7 @@ export class SessionManager {
214
265
  lastRestartAt: null,
215
266
  _stoppedByUser: false,
216
267
  _wasActiveBeforeRestart: false,
217
- _apiRetryCount: 0,
268
+ _apiRetry: { count: 0 },
218
269
  _turnCount: 0,
219
270
  _claudeTurnCount: 0,
220
271
  _namingAttempts: 0,
@@ -235,8 +286,16 @@ export class SessionManager {
235
286
  * Create a git worktree for a session. Creates a new branch and worktree
236
287
  * as a sibling directory of the project root.
237
288
  * Returns the worktree path on success, or null on failure.
289
+ *
290
+ * @param targetBranch — use this as the worktree branch name instead of
291
+ * the default `wt/{shortId}`. The orchestrator uses this to create the
292
+ * worktree directly on the desired feature branch so Claude doesn't
293
+ * need to create a second branch.
294
+ * @param baseBranch — create the worktree branch from this ref (e.g.
295
+ * 'main'). Defaults to auto-detecting the default branch. Prevents
296
+ * worktrees from accidentally branching off a random HEAD.
238
297
  */
239
- async createWorktree(sessionId, workingDir) {
298
+ async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
240
299
  const session = this.sessions.get(sessionId);
241
300
  if (!session)
242
301
  return null;
@@ -253,23 +312,72 @@ export class SessionManager {
253
312
  console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
254
313
  return null;
255
314
  }
256
- const prefix = this.getWorktreeBranchPrefix();
257
315
  const shortId = sessionId.slice(0, 8);
258
- const branchName = `${prefix}${shortId}`;
316
+ const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
259
317
  const projectName = path.basename(repoRoot);
260
318
  const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
319
+ // Auto-detect the default branch if baseBranch not specified.
320
+ // Tries origin/HEAD, then falls back to common names.
321
+ let resolvedBase = baseBranch;
322
+ if (!resolvedBase) {
323
+ resolvedBase = await this.detectDefaultBranch(repoRoot, env) ?? undefined;
324
+ }
325
+ // Determine if this is an ephemeral branch (wt/ prefix, generated by us)
326
+ // vs a caller-supplied branch name (e.g. fix/feature-xyz from orchestrator).
327
+ // Caller-supplied branches must NEVER be force-deleted — they may contain
328
+ // unique commits from a previous session or manual work.
329
+ const isEphemeralBranch = !targetBranch;
330
+ // Check if the target branch already exists as a local branch
331
+ let branchExists = false;
332
+ try {
333
+ await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { cwd: repoRoot, env, timeout: 3000 });
334
+ branchExists = true;
335
+ }
336
+ catch {
337
+ // Branch doesn't exist — will be created
338
+ }
261
339
  // Clean up stale state from previous failed attempts:
262
340
  // 1. Prune orphaned worktree entries (directory gone but git still tracks it)
263
341
  await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
264
342
  .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
343
+ // 2. Remove existing worktree directory if leftover from a partial failure.
344
+ // If git doesn't recognise it as a worktree, force-remove the directory
345
+ // so that `git worktree add` below doesn't fail with "already exists".
266
346
  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], {
347
+ .catch((e) => {
348
+ console.debug(`[worktree] remove prior worktree (expected if fresh):`, e instanceof Error ? e.message : e);
349
+ // Git doesn't know about it nuke the stale directory if it still exists
350
+ if (existsSync(worktreePath)) {
351
+ try {
352
+ rmSync(worktreePath, { recursive: true, force: true });
353
+ console.log(`[worktree] Force-removed stale directory: ${worktreePath}`);
354
+ }
355
+ catch (rmErr) {
356
+ console.warn(`[worktree] Failed to force-remove stale directory ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
357
+ }
358
+ }
359
+ });
360
+ // 3. Only delete ephemeral branches (wt/*) during cleanup — never caller-supplied ones
361
+ if (isEphemeralBranch && branchExists) {
362
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
363
+ .catch((e) => console.debug(`[worktree] ephemeral branch cleanup:`, e instanceof Error ? e.message : e));
364
+ branchExists = false;
365
+ }
366
+ // Create the worktree:
367
+ // - Existing branch: check it out in the worktree (no -b)
368
+ // - New branch: create with -b, branching from the resolved base
369
+ let worktreeArgs;
370
+ if (branchExists) {
371
+ worktreeArgs = ['worktree', 'add', worktreePath, branchName];
372
+ console.log(`[worktree] Using existing branch ${branchName}`);
373
+ }
374
+ else {
375
+ worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath];
376
+ if (resolvedBase)
377
+ worktreeArgs.push(resolvedBase);
378
+ console.log(`[worktree] Creating new branch ${branchName}${resolvedBase ? ` from ${resolvedBase}` : ''}`);
379
+ }
380
+ await execFileAsync('git', worktreeArgs, {
273
381
  cwd: repoRoot,
274
382
  env,
275
383
  timeout: 15000,
@@ -363,8 +471,11 @@ export class SessionManager {
363
471
  /**
364
472
  * Clean up a git worktree and its branch. Runs asynchronously and logs errors
365
473
  * but never throws — session deletion must not be blocked by cleanup failures.
474
+ * Retries once on failure after a short delay.
366
475
  */
367
- cleanupWorktree(worktreePath, repoDir) {
476
+ cleanupWorktree(worktreePath, repoDir, attempt = 1) {
477
+ const MAX_CLEANUP_ATTEMPTS = 2;
478
+ const RETRY_DELAY_MS = 3000;
368
479
  void (async () => {
369
480
  try {
370
481
  // Resolve the actual repo root (repoDir may itself be a worktree)
@@ -384,10 +495,62 @@ export class SessionManager {
384
495
  .catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
385
496
  }
386
497
  catch (err) {
387
- console.warn(`[worktree] Failed to clean up worktree ${worktreePath}:`, err instanceof Error ? err.message : err);
498
+ const errMsg = err instanceof Error ? err.message : String(err);
499
+ if (attempt < MAX_CLEANUP_ATTEMPTS) {
500
+ console.warn(`[worktree] Failed to clean up worktree ${worktreePath} (attempt ${attempt}/${MAX_CLEANUP_ATTEMPTS}): ${errMsg} — retrying in ${RETRY_DELAY_MS}ms`);
501
+ setTimeout(() => this.cleanupWorktree(worktreePath, repoDir, attempt + 1), RETRY_DELAY_MS);
502
+ }
503
+ else {
504
+ console.error(`[worktree] Failed to clean up worktree ${worktreePath} after ${MAX_CLEANUP_ATTEMPTS} attempts: ${errMsg}`);
505
+ // Last resort: force-remove the directory so it doesn't block future
506
+ // worktree creation or leave the session in a broken restart loop.
507
+ if (existsSync(worktreePath)) {
508
+ try {
509
+ rmSync(worktreePath, { recursive: true, force: true });
510
+ console.log(`[worktree] Force-removed stale worktree directory: ${worktreePath}`);
511
+ }
512
+ catch (rmErr) {
513
+ console.error(`[worktree] Failed to force-remove ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
514
+ }
515
+ }
516
+ }
388
517
  }
389
518
  })();
390
519
  }
520
+ /**
521
+ * Detect the default branch of a repository (main, master, etc.).
522
+ * Tries `git symbolic-ref refs/remotes/origin/HEAD` first, then checks
523
+ * for common branch names. Returns null if detection fails.
524
+ */
525
+ async detectDefaultBranch(repoRoot, env) {
526
+ // Try origin/HEAD (set by git clone or git remote set-head)
527
+ try {
528
+ const { stdout } = await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: repoRoot, env, timeout: 5000 });
529
+ const ref = stdout.trim(); // e.g. "refs/remotes/origin/main"
530
+ if (ref) {
531
+ const branch = ref.replace('refs/remotes/origin/', '');
532
+ console.log(`[worktree] Detected default branch from origin/HEAD: ${branch}`);
533
+ return branch;
534
+ }
535
+ }
536
+ catch {
537
+ // origin/HEAD not set — fall through to heuristics
538
+ }
539
+ // Check for common default branch names — use show-ref to verify
540
+ // these are actual local branches, not tags or other refs
541
+ for (const candidate of ['main', 'master']) {
542
+ try {
543
+ await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { cwd: repoRoot, env, timeout: 3000 });
544
+ console.log(`[worktree] Detected default branch by name: ${candidate}`);
545
+ return candidate;
546
+ }
547
+ catch {
548
+ // branch doesn't exist, try next
549
+ }
550
+ }
551
+ console.warn(`[worktree] Could not detect default branch for ${repoRoot} — worktree will branch from HEAD`);
552
+ return null;
553
+ }
391
554
  /** Get the configured worktree branch prefix (defaults to 'wt/'). */
392
555
  getWorktreeBranchPrefix() {
393
556
  return this.archive.getSetting('worktree_branch_prefix', 'wt/');
@@ -424,30 +587,7 @@ export class SessionManager {
424
587
  }
425
588
  /** Get all sessions that have pending prompts (waiting for approval or answer). */
426
589
  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;
590
+ return this.promptRouter.getPendingPrompts();
451
591
  }
452
592
  /** Clear the isProcessing flag for a session and broadcast the update. */
453
593
  clearProcessingFlag(sessionId) {
@@ -457,9 +597,8 @@ export class SessionManager {
457
597
  this._globalBroadcast?.({ type: 'sessions_updated' });
458
598
  }
459
599
  }
460
- list() {
461
- return Array.from(this.sessions.values())
462
- .map((s) => ({
600
+ serializeSession(s) {
601
+ return {
463
602
  id: s.id,
464
603
  name: s.name,
465
604
  created: s.created,
@@ -471,24 +610,17 @@ export class SessionManager {
471
610
  connectedClients: s.clients.size,
472
611
  lastActivity: new Date(s._lastActivityAt).toISOString(),
473
612
  source: s.source,
474
- }));
613
+ };
614
+ }
615
+ list() {
616
+ return Array.from(this.sessions.values())
617
+ .filter((s) => s.source !== 'orchestrator')
618
+ .map((s) => this.serializeSession(s));
475
619
  }
476
620
  /** List ALL sessions including orchestrator — used by orchestrator cleanup endpoints. */
477
621
  listAll() {
478
622
  return Array.from(this.sessions.values())
479
- .map((s) => ({
480
- id: s.id,
481
- name: s.name,
482
- created: s.created,
483
- active: s.claudeProcess?.isAlive() ?? false,
484
- isProcessing: s.isProcessing,
485
- workingDir: s.workingDir,
486
- groupDir: s.groupDir,
487
- worktreePath: s.worktreePath,
488
- connectedClients: s.clients.size,
489
- lastActivity: new Date(s._lastActivityAt).toISOString(),
490
- source: s.source,
491
- }));
623
+ .map((s) => this.serializeSession(s));
492
624
  }
493
625
  rename(sessionId, newName) {
494
626
  const session = this.sessions.get(sessionId);
@@ -572,21 +704,35 @@ export class SessionManager {
572
704
  return false;
573
705
  // Prevent auto-restart when deleting
574
706
  session._stoppedByUser = true;
575
- if (session._apiRetryTimer)
576
- clearTimeout(session._apiRetryTimer);
707
+ if (session._apiRetry.timer)
708
+ clearTimeout(session._apiRetry.timer);
577
709
  if (session._namingTimer)
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
@@ -938,18 +892,23 @@ export class SessionManager {
938
892
  */
939
893
  handleApiRetry(session, sessionId, result) {
940
894
  if (!session._lastUserInput || !this.isRetryableApiError(result)) {
941
- session._apiRetryCount = 0;
895
+ session._apiRetry.count = 0;
896
+ session._apiRetry.scheduled = 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
- session._apiRetryCount = 0;
902
+ session._apiRetry.count = 0;
903
+ session._apiRetry.scheduled = false;
948
904
  return false;
949
905
  }
950
- if (session._apiRetryCount < MAX_API_RETRIES) {
951
- session._apiRetryCount++;
952
- const attempt = session._apiRetryCount;
906
+ if (session._apiRetry.count < MAX_API_RETRIES) {
907
+ // Prevent duplicate scheduling from concurrent error paths
908
+ if (session._apiRetry.scheduled)
909
+ return true;
910
+ session._apiRetry.count++;
911
+ const attempt = session._apiRetry.count;
953
912
  const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
954
913
  const retryMsg = {
955
914
  type: 'system_message',
@@ -959,10 +918,12 @@ export class SessionManager {
959
918
  this.addToHistory(session, retryMsg);
960
919
  this.broadcast(session, retryMsg);
961
920
  console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
962
- if (session._apiRetryTimer)
963
- clearTimeout(session._apiRetryTimer);
964
- session._apiRetryTimer = setTimeout(() => {
965
- session._apiRetryTimer = undefined;
921
+ if (session._apiRetry.timer)
922
+ clearTimeout(session._apiRetry.timer);
923
+ session._apiRetry.scheduled = true;
924
+ session._apiRetry.timer = setTimeout(() => {
925
+ session._apiRetry.timer = undefined;
926
+ session._apiRetry.scheduled = 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}`);
@@ -978,7 +939,8 @@ export class SessionManager {
978
939
  };
979
940
  this.addToHistory(session, exhaustedMsg);
980
941
  this.broadcast(session, exhaustedMsg);
981
- session._apiRetryCount = 0;
942
+ session._apiRetry.count = 0;
943
+ session._apiRetry.scheduled = false;
982
944
  return false;
983
945
  }
984
946
  /**
@@ -986,7 +948,8 @@ export class SessionManager {
986
948
  * and trigger session naming if needed.
987
949
  */
988
950
  finalizeResult(session, sessionId, result, isError) {
989
- session._apiRetryCount = 0;
951
+ session._apiRetry.count = 0;
952
+ session._apiRetry.scheduled = 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.
@@ -1144,15 +1013,33 @@ export class SessionManager {
1144
1013
  const combined = context + '\n\n' + data;
1145
1014
  session._lastUserInput = combined;
1146
1015
  session._lastUserInputAt = Date.now();
1147
- session._apiRetryCount = 0;
1016
+ session._apiRetry.count = 0;
1148
1017
  if (!session.isProcessing) {
1149
1018
  session.isProcessing = true;
1150
1019
  this._globalBroadcast?.({ type: 'sessions_updated' });
1151
1020
  }
1152
- session.claudeProcess?.sendMessage(combined);
1021
+ if (session.claudeProcess && !session.claudeProcess.isReady()) {
1022
+ void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(combined));
1023
+ }
1024
+ else {
1025
+ session.claudeProcess?.sendMessage(combined);
1026
+ }
1153
1027
  return;
1154
1028
  }
1155
1029
  }
1030
+ // Process just started — if not ready yet (OpenCode needs server init),
1031
+ // queue the message via waitForReady.
1032
+ if (session.claudeProcess && !session.claudeProcess.isReady()) {
1033
+ session._lastUserInput = data;
1034
+ session._lastUserInputAt = Date.now();
1035
+ session._apiRetry.count = 0;
1036
+ if (!session.isProcessing) {
1037
+ session.isProcessing = true;
1038
+ this._globalBroadcast?.({ type: 'sessions_updated' });
1039
+ }
1040
+ void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(data));
1041
+ return;
1042
+ }
1156
1043
  }
1157
1044
  // Track turn count; retry naming on subsequent interactions if still unnamed
1158
1045
  if (session._turnCount === 0 && session.name.startsWith('hub:')) {
@@ -1164,7 +1051,7 @@ export class SessionManager {
1164
1051
  }
1165
1052
  session._lastUserInput = data;
1166
1053
  session._lastUserInputAt = Date.now();
1167
- session._apiRetryCount = 0;
1054
+ session._apiRetry.count = 0;
1168
1055
  if (!session.isProcessing) {
1169
1056
  session.isProcessing = true;
1170
1057
  this._globalBroadcast?.({ type: 'sessions_updated' });
@@ -1172,436 +1059,43 @@ export class SessionManager {
1172
1059
  session.claudeProcess?.sendMessage(data);
1173
1060
  }
1174
1061
  /**
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.
1062
+ * Route a user's prompt response to the correct handler.
1063
+ * Delegates to PromptRouter.
1178
1064
  */
1179
1065
  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);
1066
+ this.promptRouter.sendPromptResponse(sessionId, value, requestId);
1343
1067
  }
1344
1068
  /**
1345
1069
  * Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
1346
1070
  * and returns a Promise that resolves when the user approves/denies.
1071
+ * Delegates to PromptRouter.
1347
1072
  */
1348
1073
  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
- });
1074
+ return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
1542
1075
  }
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';
1076
+ getSessionProvider(sessionId) {
1077
+ return this.sessions.get(sessionId)?.provider ?? 'claude';
1560
1078
  }
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;
1079
+ /** Update the provider for a session and restart with the new provider process. */
1080
+ setProvider(sessionId, provider) {
1081
+ const session = this.sessions.get(sessionId);
1082
+ if (!session)
1083
+ return false;
1084
+ if (session.provider === provider)
1085
+ return true;
1086
+ session.provider = provider;
1087
+ session.claudeSessionId = null;
1088
+ this.persistToDiskDebounced();
1089
+ if (session.claudeProcess?.isAlive()) {
1090
+ this.stopClaude(sessionId);
1091
+ session._stoppedByUser = false;
1092
+ setTimeout(() => {
1093
+ if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1094
+ this.startClaude(sessionId);
1582
1095
  }
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}?`;
1096
+ }, 500);
1604
1097
  }
1098
+ return true;
1605
1099
  }
1606
1100
  /** Update the model for a session and restart Claude with the new model. */
1607
1101
  setModel(sessionId, model) {
@@ -1610,15 +1104,16 @@ export class SessionManager {
1610
1104
  return false;
1611
1105
  session.model = model || undefined;
1612
1106
  this.persistToDiskDebounced();
1613
- // Restart Claude with the new model if it's running
1107
+ // Restart Claude with the new model if it's running.
1108
+ // Use stopClaudeAndWait to ensure the old process fully exits before
1109
+ // spawning a new one — avoids concurrent processes in the same worktree.
1614
1110
  if (session.claudeProcess?.isAlive()) {
1615
- this.stopClaude(sessionId);
1616
- session._stoppedByUser = false;
1617
- setTimeout(() => {
1618
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1111
+ void this.stopClaudeAndWait(sessionId).then(() => {
1112
+ if (this.sessions.has(sessionId)) {
1113
+ session._stoppedByUser = false;
1619
1114
  this.startClaude(sessionId);
1620
1115
  }
1621
- }, 500);
1116
+ });
1622
1117
  }
1623
1118
  return true;
1624
1119
  }
@@ -1639,49 +1134,29 @@ export class SessionManager {
1639
1134
  const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
1640
1135
  this.addToHistory(session, sysMsg);
1641
1136
  this.broadcast(session, sysMsg);
1642
- // Restart Claude with the new permission mode if it's running
1137
+ // Restart Claude with the new permission mode if it's running.
1138
+ // Use stopClaudeAndWait to ensure the old process fully exits before
1139
+ // spawning a new one — avoids concurrent processes in the same worktree.
1643
1140
  if (session.claudeProcess?.isAlive()) {
1644
- this.stopClaude(sessionId);
1645
- session._stoppedByUser = false;
1646
- setTimeout(() => {
1647
- if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1141
+ void this.stopClaudeAndWait(sessionId).then(() => {
1142
+ if (this.sessions.has(sessionId)) {
1143
+ session._stoppedByUser = false;
1648
1144
  this.startClaude(sessionId);
1649
1145
  }
1650
- }, 500);
1146
+ });
1651
1147
  }
1652
1148
  return true;
1653
1149
  }
1150
+ /** Stop the Claude process for a session. Delegates to SessionLifecycle. */
1654
1151
  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
- }
1152
+ this.sessionLifecycle.stopClaude(sessionId);
1665
1153
  }
1666
1154
  /**
1667
1155
  * 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).
1156
+ * Delegates to SessionLifecycle.
1670
1157
  */
1671
1158
  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();
1159
+ return this.sessionLifecycle.stopClaudeAndWait(sessionId);
1685
1160
  }
1686
1161
  // ---------------------------------------------------------------------------
1687
1162
  // Helpers
@@ -1927,7 +1402,7 @@ export class SessionManager {
1927
1402
  restoreActiveSessions() {
1928
1403
  const toRestore = [];
1929
1404
  for (const session of this.sessions.values()) {
1930
- if (session._wasActiveBeforeRestart && session.claudeSessionId) {
1405
+ if (session._wasActiveBeforeRestart && session.claudeSessionId && session.source !== 'webhook') {
1931
1406
  toRestore.push(session);
1932
1407
  }
1933
1408
  }