codekin 0.5.0 → 0.5.2

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 (47) hide show
  1. package/dist/assets/index-84JYN21S.js +178 -0
  2. package/dist/assets/index-C0Iuc3iT.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +30 -28
  5. package/server/dist/approval-manager.d.ts +2 -0
  6. package/server/dist/approval-manager.js +4 -1
  7. package/server/dist/approval-manager.js.map +1 -1
  8. package/server/dist/claude-process.d.ts +26 -26
  9. package/server/dist/claude-process.js +76 -137
  10. package/server/dist/claude-process.js.map +1 -1
  11. package/server/dist/native-permissions.js +1 -1
  12. package/server/dist/native-permissions.js.map +1 -1
  13. package/server/dist/orchestrator-children.js +16 -10
  14. package/server/dist/orchestrator-children.js.map +1 -1
  15. package/server/dist/orchestrator-manager.js +4 -1
  16. package/server/dist/orchestrator-manager.js.map +1 -1
  17. package/server/dist/orchestrator-reports.js +1 -1
  18. package/server/dist/orchestrator-routes.d.ts +3 -1
  19. package/server/dist/orchestrator-routes.js +3 -3
  20. package/server/dist/orchestrator-routes.js.map +1 -1
  21. package/server/dist/plan-manager.d.ts +74 -0
  22. package/server/dist/plan-manager.js +121 -0
  23. package/server/dist/plan-manager.js.map +1 -0
  24. package/server/dist/session-archive.js +2 -2
  25. package/server/dist/session-archive.js.map +1 -1
  26. package/server/dist/session-manager.d.ts +56 -5
  27. package/server/dist/session-manager.js +389 -95
  28. package/server/dist/session-manager.js.map +1 -1
  29. package/server/dist/session-persistence.js +4 -1
  30. package/server/dist/session-persistence.js.map +1 -1
  31. package/server/dist/tsconfig.tsbuildinfo +1 -1
  32. package/server/dist/types.d.ts +15 -4
  33. package/server/dist/types.js +1 -1
  34. package/server/dist/types.js.map +1 -1
  35. package/server/dist/upload-routes.js +3 -2
  36. package/server/dist/upload-routes.js.map +1 -1
  37. package/server/dist/webhook-config.js +9 -0
  38. package/server/dist/webhook-config.js.map +1 -1
  39. package/server/dist/webhook-handler.js +13 -0
  40. package/server/dist/webhook-handler.js.map +1 -1
  41. package/server/dist/webhook-types.d.ts +1 -0
  42. package/server/dist/workflow-loader.js +21 -21
  43. package/server/dist/workflow-loader.js.map +1 -1
  44. package/server/dist/ws-server.js +4 -0
  45. package/server/dist/ws-server.js.map +1 -1
  46. package/dist/assets/index-BwKZeT4V.css +0 -1
  47. package/dist/assets/index-CfBnNU24.js +0 -186
@@ -24,6 +24,7 @@ import { homedir } from 'os';
24
24
  import path from 'path';
25
25
  import { promisify } from 'util';
26
26
  import { ClaudeProcess } from './claude-process.js';
27
+ import { PlanManager } from './plan-manager.js';
27
28
  import { SessionArchive } from './session-archive.js';
28
29
  import { cleanupWorkspace } from './webhook-workspace.js';
29
30
  import { PORT } from './config.js';
@@ -36,12 +37,20 @@ import { evaluateRestart } from './session-restart-scheduler.js';
36
37
  const execFileAsync = promisify(execFile);
37
38
  /** Max messages retained in a session's output history buffer. */
38
39
  const MAX_HISTORY = 2000;
39
- /** No-output duration before emitting a stall warning (5 minutes). */
40
- const STALL_TIMEOUT_MS = 5 * 60 * 1000;
41
40
  /** Max API error retries per turn before giving up. */
42
41
  const MAX_API_RETRIES = 3;
43
42
  /** Base delay for API error retry (doubles each attempt: 3s, 6s, 12s). */
44
43
  const API_RETRY_BASE_DELAY_MS = 3000;
44
+ /** How long a session can be idle (no clients, no activity) before its process is stopped. */
45
+ const IDLE_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
46
+ /** How often to check for idle sessions. */
47
+ const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
48
+ /** How old a dead session must be before automatic pruning (7 days). */
49
+ const STALE_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000;
50
+ /** Number of Claude turns before showing a context compression warning. */
51
+ const CONTEXT_WARNING_TURN_THRESHOLD = 15;
52
+ /** Second warning at this threshold. */
53
+ const CONTEXT_CRITICAL_TURN_THRESHOLD = 25;
45
54
  /** Patterns in result text that indicate a transient API error worth retrying. */
46
55
  const API_RETRY_PATTERNS = [
47
56
  /api_error/i,
@@ -80,6 +89,8 @@ export class SessionManager {
80
89
  sessionPersistence;
81
90
  /** Delegated diff operations (git diff, discard changes). */
82
91
  diffManager;
92
+ /** Interval handle for the idle session reaper. */
93
+ _idleReaperInterval = null;
83
94
  constructor() {
84
95
  this.archive = new SessionArchive();
85
96
  this._approvalManager = new ApprovalManager();
@@ -91,6 +102,62 @@ export class SessionManager {
91
102
  rename: (sessionId, newName) => this.rename(sessionId, newName),
92
103
  });
93
104
  this.sessionPersistence.restoreFromDisk();
105
+ // Wire PlanManager events for restored sessions
106
+ for (const session of this.sessions.values()) {
107
+ this.wirePlanManager(session);
108
+ }
109
+ // Start idle session reaper
110
+ this._idleReaperInterval = setInterval(() => this.reapIdleSessions(), IDLE_CHECK_INTERVAL_MS);
111
+ }
112
+ /**
113
+ * Stop Claude processes for sessions that have been idle too long.
114
+ * A session is idle when it has no connected clients and no activity
115
+ * for IDLE_SESSION_TIMEOUT_MS. Only stops the process — does not delete
116
+ * the session, so it can be resumed later via --resume.
117
+ * Headless sessions (webhook, workflow, stepflow) are exempt.
118
+ */
119
+ reapIdleSessions() {
120
+ const now = Date.now();
121
+ for (const session of this.sessions.values()) {
122
+ // Skip headless sessions — they are managed by their own lifecycles
123
+ if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')
124
+ continue;
125
+ // Skip sessions with connected clients or no running process
126
+ if (session.clients.size > 0 || !session.claudeProcess?.isAlive())
127
+ continue;
128
+ // Skip sessions that are actively processing
129
+ if (session.isProcessing)
130
+ continue;
131
+ const idleMs = now - session._lastActivityAt;
132
+ if (idleMs > IDLE_SESSION_TIMEOUT_MS) {
133
+ console.log(`[idle-reaper] stopping idle session=${session.id} name="${session.name}" idle=${Math.round(idleMs / 60_000)}min`);
134
+ session._stoppedByUser = true; // prevent auto-restart
135
+ session.claudeProcess.removeAllListeners();
136
+ session.claudeProcess.stop();
137
+ session.claudeProcess = null;
138
+ session.isProcessing = false;
139
+ const msg = { type: 'system_message', subtype: 'exit', text: 'Claude process stopped due to inactivity. It will resume when you send a new message.' };
140
+ this.addToHistory(session, msg);
141
+ this.persistToDiskDebounced();
142
+ this._globalBroadcast?.({ type: 'sessions_updated' });
143
+ }
144
+ }
145
+ // Prune stale sessions: no process, no clients, older than STALE_SESSION_AGE_MS
146
+ const staleIds = [];
147
+ for (const session of this.sessions.values()) {
148
+ if (session.claudeProcess?.isAlive())
149
+ continue;
150
+ if (session.clients.size > 0)
151
+ continue;
152
+ const ageMs = now - new Date(session.created).getTime();
153
+ if (ageMs > STALE_SESSION_AGE_MS) {
154
+ staleIds.push(session.id);
155
+ }
156
+ }
157
+ for (const id of staleIds) {
158
+ console.log(`[idle-reaper] pruning stale session=${id} (age > ${STALE_SESSION_AGE_MS / 86_400_000}d)`);
159
+ this.delete(id);
160
+ }
94
161
  }
95
162
  // ---------------------------------------------------------------------------
96
163
  // Approval — direct accessor (callers use sessions.approvalManager.xxx)
@@ -143,16 +210,19 @@ export class SessionManager {
143
210
  restartCount: 0,
144
211
  lastRestartAt: null,
145
212
  _stoppedByUser: false,
146
- _stallTimer: null,
147
213
  _wasActiveBeforeRestart: false,
148
214
  _apiRetryCount: 0,
149
215
  _turnCount: 0,
216
+ _claudeTurnCount: 0,
150
217
  _namingAttempts: 0,
151
218
  isProcessing: false,
152
219
  pendingControlRequests: new Map(),
153
220
  pendingToolApprovals: new Map(),
154
221
  _leaveGraceTimer: null,
222
+ _lastActivityAt: Date.now(),
223
+ planManager: new PlanManager(),
155
224
  };
225
+ this.wirePlanManager(session);
156
226
  this.sessions.set(id, session);
157
227
  this.persistToDisk();
158
228
  this._globalBroadcast?.({ type: 'sessions_updated' });
@@ -327,6 +397,11 @@ export class SessionManager {
327
397
  * The `willRestart` flag indicates whether the session will be auto-restarted. */
328
398
  onSessionExit(listener) {
329
399
  this._exitListeners.push(listener);
400
+ return () => {
401
+ const idx = this._exitListeners.indexOf(listener);
402
+ if (idx >= 0)
403
+ this._exitListeners.splice(idx, 1);
404
+ };
330
405
  }
331
406
  /** Register a listener called when any session emits a prompt (permission request or question). */
332
407
  onSessionPrompt(listener) {
@@ -335,6 +410,11 @@ export class SessionManager {
335
410
  /** Register a listener called when any session completes a turn (result event). */
336
411
  onSessionResult(listener) {
337
412
  this._resultListeners.push(listener);
413
+ return () => {
414
+ const idx = this._resultListeners.indexOf(listener);
415
+ if (idx >= 0)
416
+ this._resultListeners.splice(idx, 1);
417
+ };
338
418
  }
339
419
  get(id) {
340
420
  return this.sessions.get(id);
@@ -386,7 +466,7 @@ export class SessionManager {
386
466
  groupDir: s.groupDir,
387
467
  worktreePath: s.worktreePath,
388
468
  connectedClients: s.clients.size,
389
- lastActivity: s.created,
469
+ lastActivity: new Date(s._lastActivityAt).toISOString(),
390
470
  source: s.source,
391
471
  }));
392
472
  }
@@ -403,7 +483,7 @@ export class SessionManager {
403
483
  groupDir: s.groupDir,
404
484
  worktreePath: s.worktreePath,
405
485
  connectedClients: s.clients.size,
406
- lastActivity: s.created,
486
+ lastActivity: new Date(s._lastActivityAt).toISOString(),
407
487
  source: s.source,
408
488
  }));
409
489
  }
@@ -430,6 +510,7 @@ export class SessionManager {
430
510
  }
431
511
  session.clients.add(ws);
432
512
  this.clientSessionMap.set(ws, sessionId);
513
+ session._lastActivityAt = Date.now();
433
514
  // Re-broadcast pending tool approval prompts (PreToolUse hook path)
434
515
  for (const pending of session.pendingToolApprovals.values()) {
435
516
  if (pending.promptMsg) {
@@ -488,7 +569,6 @@ export class SessionManager {
488
569
  return false;
489
570
  // Prevent auto-restart when deleting
490
571
  session._stoppedByUser = true;
491
- this.clearStallTimer(session);
492
572
  if (session._apiRetryTimer)
493
573
  clearTimeout(session._apiRetryTimer);
494
574
  if (session._namingTimer)
@@ -599,17 +679,47 @@ export class SessionManager {
599
679
  const repoDir = session.groupDir ?? session.workingDir;
600
680
  const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
601
681
  const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
602
- const cp = new ClaudeProcess(session.workingDir, session.claudeSessionId || undefined, extraEnv, session.model, session.permissionMode, resume, mergedAllowedTools);
682
+ const cp = new ClaudeProcess(session.workingDir, {
683
+ sessionId: session.claudeSessionId || undefined,
684
+ extraEnv,
685
+ model: session.model,
686
+ permissionMode: session.permissionMode,
687
+ resume,
688
+ allowedTools: mergedAllowedTools,
689
+ });
603
690
  this.wireClaudeEvents(cp, session, sessionId);
604
691
  cp.start();
605
692
  session.claudeProcess = cp;
606
- this.resetStallTimer(session);
607
693
  this._globalBroadcast?.({ type: 'sessions_updated' });
608
694
  const startMsg = { type: 'claude_started', sessionId };
609
695
  this.addToHistory(session, startMsg);
610
696
  this.broadcast(session, startMsg);
611
697
  return true;
612
698
  }
699
+ /**
700
+ * Wait for a session's Claude process to emit its system_init event,
701
+ * indicating it is ready to accept input. Resolves immediately if the
702
+ * session already has a claudeSessionId (process previously initialized).
703
+ * Times out after `timeoutMs` (default 30s) to avoid hanging indefinitely.
704
+ */
705
+ waitForReady(sessionId, timeoutMs = 30_000) {
706
+ const session = this.sessions.get(sessionId);
707
+ if (!session?.claudeProcess)
708
+ return Promise.resolve();
709
+ // If the process already completed init in a prior turn, resolve immediately
710
+ if (session.claudeSessionId)
711
+ return Promise.resolve();
712
+ return new Promise((resolve) => {
713
+ const timer = setTimeout(() => {
714
+ console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`);
715
+ resolve();
716
+ }, timeoutMs);
717
+ session.claudeProcess.once('system_init', () => {
718
+ clearTimeout(timer);
719
+ resolve();
720
+ });
721
+ });
722
+ }
613
723
  /**
614
724
  * Attach all ClaudeProcess event listeners for a session.
615
725
  * Extracted from startClaude() to keep that method focused on process setup.
@@ -622,11 +732,23 @@ export class SessionManager {
622
732
  cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
623
733
  cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
624
734
  cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
625
- cp.on('planning_mode', (active) => { this.broadcastAndHistory(session, { type: 'planning_mode', active }); });
735
+ cp.on('planning_mode', (active) => {
736
+ // Route EnterPlanMode through PlanManager for UI state tracking.
737
+ // ExitPlanMode (active=false) is ignored here — the PreToolUse hook
738
+ // is the enforcement gate, and it calls handleExitPlanModeApproval()
739
+ // which transitions PlanManager to 'reviewing'.
740
+ if (active) {
741
+ session.planManager.onEnterPlanMode();
742
+ }
743
+ // ExitPlanMode stream event intentionally ignored — hook handles it.
744
+ });
626
745
  cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
627
746
  cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
628
747
  cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
629
- cp.on('result', (result, isError) => { this.resetStallTimer(session); this.handleClaudeResult(session, sessionId, result, isError); });
748
+ cp.on('result', (result, isError) => {
749
+ session.planManager.onTurnEnd();
750
+ this.handleClaudeResult(session, sessionId, result, isError);
751
+ });
630
752
  cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
631
753
  cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(session, sessionId, code, signal); });
632
754
  }
@@ -635,6 +757,20 @@ export class SessionManager {
635
757
  this.addToHistory(session, msg);
636
758
  this.broadcast(session, msg);
637
759
  }
760
+ /**
761
+ * Wire PlanManager events for a session.
762
+ * Called once at session creation (not per-process, since PlanManager outlives restarts).
763
+ * Idempotent — guards against double-wiring on restore + restart.
764
+ */
765
+ wirePlanManager(session) {
766
+ if (session._planManagerWired)
767
+ return;
768
+ session._planManagerWired = true;
769
+ const pm = session.planManager;
770
+ pm.on('planning_mode', (active) => {
771
+ this.broadcastAndHistory(session, { type: 'planning_mode', active });
772
+ });
773
+ }
638
774
  onSystemInit(cp, session, model) {
639
775
  session.claudeSessionId = cp.getSessionId();
640
776
  // Only show model message on first init or when model actually changes
@@ -644,30 +780,24 @@ export class SessionManager {
644
780
  }
645
781
  }
646
782
  onTextEvent(session, sessionId, text) {
647
- this.resetStallTimer(session);
648
783
  this.broadcastAndHistory(session, { type: 'output', data: text });
649
784
  if (session.name.startsWith('hub:') && !session._namingTimer) {
650
785
  this.scheduleSessionNaming(sessionId);
651
786
  }
652
787
  }
653
788
  onThinkingEvent(session, summary) {
654
- this.resetStallTimer(session);
655
789
  this.broadcast(session, { type: 'thinking', summary });
656
790
  }
657
791
  onToolOutputEvent(session, content, isError) {
658
- this.resetStallTimer(session);
659
792
  this.broadcastAndHistory(session, { type: 'tool_output', content, isError });
660
793
  }
661
794
  onImageEvent(session, base64, mediaType) {
662
- this.resetStallTimer(session);
663
795
  this.broadcastAndHistory(session, { type: 'image', base64, mediaType });
664
796
  }
665
797
  onToolActiveEvent(session, toolName, toolInput) {
666
- this.resetStallTimer(session);
667
798
  this.broadcastAndHistory(session, { type: 'tool_active', toolName, toolInput });
668
799
  }
669
800
  onToolDoneEvent(session, toolName, summary) {
670
- this.resetStallTimer(session);
671
801
  this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
672
802
  }
673
803
  onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
@@ -760,63 +890,121 @@ export class SessionManager {
760
890
  */
761
891
  handleClaudeResult(session, sessionId, result, isError) {
762
892
  session.isProcessing = false;
893
+ session._claudeTurnCount++;
763
894
  this._globalBroadcast?.({ type: 'sessions_updated' });
764
- // Detect transient API errors and auto-retry the last user message
765
- if (isError && session._lastUserInput && this.isRetryableApiError(result)) {
766
- if (session._apiRetryCount < MAX_API_RETRIES) {
767
- session._apiRetryCount++;
768
- const attempt = session._apiRetryCount;
769
- const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
770
- const retryMsg = {
771
- type: 'system_message',
772
- subtype: 'restart',
773
- text: `API error (transient). Retrying automatically in ${delay / 1000}s (attempt ${attempt}/${MAX_API_RETRIES})...`,
774
- };
775
- this.addToHistory(session, retryMsg);
776
- this.broadcast(session, retryMsg);
777
- console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
778
- // Clear any previous retry timer
779
- if (session._apiRetryTimer)
780
- clearTimeout(session._apiRetryTimer);
781
- session._apiRetryTimer = setTimeout(() => {
782
- session._apiRetryTimer = undefined;
783
- if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
784
- return;
785
- console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
786
- session.claudeProcess.sendMessage(session._lastUserInput);
787
- }, delay);
788
- return; // Don't broadcast result — we're retrying
789
- }
790
- // All retries exhausted
791
- const exhaustedMsg = {
895
+ // Attempt API retry for transient errors returns true if a retry was scheduled
896
+ if (isError && this.handleApiRetry(session, sessionId, result)) {
897
+ return;
898
+ }
899
+ // Warn about context window pressure at turn thresholds
900
+ this.checkContextWarning(session);
901
+ this.finalizeResult(session, sessionId, result, isError);
902
+ }
903
+ /**
904
+ * Emit a notification when the session has had enough turns that Claude's
905
+ * context window may start compressing older messages. Uses simple turn-count
906
+ * heuristic — imprecise but zero-risk and protocol-independent.
907
+ */
908
+ checkContextWarning(session) {
909
+ const turns = session._claudeTurnCount;
910
+ if (turns === CONTEXT_WARNING_TURN_THRESHOLD) {
911
+ const msg = {
912
+ type: 'system_message',
913
+ subtype: 'notification',
914
+ text: `This session has ${turns} turns. Claude may begin compressing older messages from its context window. Earlier parts of the conversation may no longer be fully available to Claude.`,
915
+ };
916
+ this.broadcastAndHistory(session, msg);
917
+ session._contextWarningShown = true;
918
+ }
919
+ else if (turns === CONTEXT_CRITICAL_TURN_THRESHOLD) {
920
+ const msg = {
792
921
  type: 'system_message',
793
- subtype: 'error',
794
- text: `API error persisted after ${MAX_API_RETRIES} retries. ${result}`,
922
+ subtype: 'notification',
923
+ text: `This session has ${turns} turns. Claude's context window is likely under pressure — older messages may have been compressed or dropped. Consider starting a new session for best results.`,
795
924
  };
796
- this.addToHistory(session, exhaustedMsg);
797
- this.broadcast(session, exhaustedMsg);
925
+ this.broadcastAndHistory(session, msg);
926
+ }
927
+ }
928
+ /**
929
+ * Detect transient API errors and schedule an automatic retry.
930
+ * Returns true if a retry was scheduled (caller should skip result broadcast).
931
+ */
932
+ handleApiRetry(session, sessionId, result) {
933
+ if (!session._lastUserInput || !this.isRetryableApiError(result)) {
798
934
  session._apiRetryCount = 0;
935
+ return false;
799
936
  }
800
- else {
801
- // Non-retryable error or successful result reset retry counter
937
+ // Skip retry if the original input is older than 60 seconds — context has likely moved on
938
+ if (session._lastUserInputAt && Date.now() - session._lastUserInputAt > 60_000) {
939
+ console.log(`[api-retry] skipping stale retry for session=${sessionId} (input age=${Math.round((Date.now() - session._lastUserInputAt) / 1000)}s)`);
802
940
  session._apiRetryCount = 0;
803
- if (isError) {
804
- const msg = { type: 'system_message', subtype: 'error', text: result };
805
- this.addToHistory(session, msg);
806
- this.broadcast(session, msg);
941
+ return false;
942
+ }
943
+ if (session._apiRetryCount < MAX_API_RETRIES) {
944
+ session._apiRetryCount++;
945
+ const attempt = session._apiRetryCount;
946
+ const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
947
+ const retryMsg = {
948
+ type: 'system_message',
949
+ subtype: 'restart',
950
+ text: `API error (transient). Retrying automatically in ${delay / 1000}s (attempt ${attempt}/${MAX_API_RETRIES})...`,
951
+ };
952
+ this.addToHistory(session, retryMsg);
953
+ this.broadcast(session, retryMsg);
954
+ console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
955
+ if (session._apiRetryTimer)
956
+ clearTimeout(session._apiRetryTimer);
957
+ session._apiRetryTimer = setTimeout(() => {
958
+ session._apiRetryTimer = undefined;
959
+ if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
960
+ return;
961
+ console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
962
+ session.claudeProcess.sendMessage(session._lastUserInput);
963
+ }, delay);
964
+ return true;
965
+ }
966
+ // All retries exhausted
967
+ const exhaustedMsg = {
968
+ type: 'system_message',
969
+ subtype: 'error',
970
+ text: `API error persisted after ${MAX_API_RETRIES} retries. ${result}`,
971
+ };
972
+ this.addToHistory(session, exhaustedMsg);
973
+ this.broadcast(session, exhaustedMsg);
974
+ session._apiRetryCount = 0;
975
+ return false;
976
+ }
977
+ /**
978
+ * Broadcast the turn result, suppress orchestrator noise, notify listeners,
979
+ * and trigger session naming if needed.
980
+ */
981
+ finalizeResult(session, sessionId, result, isError) {
982
+ session._apiRetryCount = 0;
983
+ session._lastUserInput = undefined;
984
+ session._lastUserInputAt = undefined;
985
+ if (isError) {
986
+ const msg = { type: 'system_message', subtype: 'error', text: result };
987
+ this.addToHistory(session, msg);
988
+ this.broadcast(session, msg);
989
+ }
990
+ // Suppress noise from orchestrator/agent sessions
991
+ if ((session.source === 'orchestrator' || session.source === 'agent') && !isError) {
992
+ const turnText = this.extractCurrentTurnText(session);
993
+ if (turnText && turnText.length < 80 && /^(no response requested|please approve|nothing to do|no action needed|acknowledged)[.!]?$/i.test(turnText.trim())) {
994
+ this.stripCurrentTurnOutput(session);
995
+ console.log(`[noise-filter] suppressed orchestrator noise: "${turnText.trim().slice(0, 60)}"`);
807
996
  }
808
997
  }
809
998
  const resultMsg = { type: 'result' };
810
999
  this.addToHistory(session, resultMsg);
811
1000
  this.broadcast(session, resultMsg);
812
- // Notify result listeners (orchestrator, child monitor, etc.)
813
1001
  for (const listener of this._resultListeners) {
814
1002
  try {
815
1003
  listener(sessionId, isError);
816
1004
  }
817
1005
  catch { /* listener error */ }
818
1006
  }
819
- // If session is still unnamed after first response, name it now — we have full context
1007
+ // If session is still unnamed after first response, name it now
820
1008
  if (session.name.startsWith('hub:') && session._namingAttempts === 0) {
821
1009
  if (session._namingTimer) {
822
1010
  clearTimeout(session._namingTimer);
@@ -835,7 +1023,7 @@ export class SessionManager {
835
1023
  handleClaudeExit(session, sessionId, code, signal) {
836
1024
  session.claudeProcess = null;
837
1025
  session.isProcessing = false;
838
- this.clearStallTimer(session);
1026
+ session.planManager.reset();
839
1027
  this._globalBroadcast?.({ type: 'sessions_updated' });
840
1028
  const action = evaluateRestart({
841
1029
  restartCount: session.restartCount,
@@ -916,8 +1104,11 @@ export class SessionManager {
916
1104
  const session = this.sessions.get(sessionId);
917
1105
  if (!session)
918
1106
  return;
1107
+ session._lastActivityAt = Date.now();
1108
+ // Reset stopped-by-user flag so idle-reaped sessions can auto-start
1109
+ session._stoppedByUser = false;
919
1110
  if (!session.claudeProcess?.isAlive()) {
920
- // Claude not running (e.g. after server restart) — auto-start first.
1111
+ // Claude not running (e.g. after server restart or idle reap) — auto-start first.
921
1112
  // Claude CLI in -p mode waits for first input before emitting init,
922
1113
  // so we write directly to the stdin pipe buffer (no waiting for init).
923
1114
  this.startClaude(sessionId);
@@ -930,6 +1121,7 @@ export class SessionManager {
930
1121
  if (context) {
931
1122
  const combined = context + '\n\n' + data;
932
1123
  session._lastUserInput = combined;
1124
+ session._lastUserInputAt = Date.now();
933
1125
  session._apiRetryCount = 0;
934
1126
  if (!session.isProcessing) {
935
1127
  session.isProcessing = true;
@@ -949,6 +1141,7 @@ export class SessionManager {
949
1141
  this.retrySessionNamingOnInteraction(sessionId);
950
1142
  }
951
1143
  session._lastUserInput = data;
1144
+ session._lastUserInputAt = Date.now();
952
1145
  session._apiRetryCount = 0;
953
1146
  if (!session.isProcessing) {
954
1147
  session.isProcessing = true;
@@ -965,6 +1158,9 @@ export class SessionManager {
965
1158
  const session = this.sessions.get(sessionId);
966
1159
  if (!session)
967
1160
  return;
1161
+ session._lastActivityAt = Date.now();
1162
+ // ExitPlanMode approvals are handled through the normal pendingToolApprovals
1163
+ // path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
968
1164
  // Check for pending tool approval from PreToolUse hook
969
1165
  if (!requestId) {
970
1166
  const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
@@ -1045,6 +1241,27 @@ export class SessionManager {
1045
1241
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1046
1242
  return;
1047
1243
  }
1244
+ // ExitPlanMode: route through PlanManager for state tracking.
1245
+ // The hook will convert allow→deny-with-approval-message (CLI workaround).
1246
+ if (approval.toolName === 'ExitPlanMode') {
1247
+ const first = Array.isArray(value) ? value[0] : value;
1248
+ const isDeny = first === 'deny';
1249
+ if (isDeny) {
1250
+ // Extract feedback text if present (value may be ['deny', 'feedback text'])
1251
+ const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
1252
+ const reason = session.planManager.deny(approval.requestId, feedback);
1253
+ console.log(`[plan-approval] denied: ${reason}`);
1254
+ approval.resolve({ allow: false, always: false });
1255
+ }
1256
+ else {
1257
+ session.planManager.approve(approval.requestId);
1258
+ console.log(`[plan-approval] approved`);
1259
+ approval.resolve({ allow: true, always: false });
1260
+ }
1261
+ session.pendingToolApprovals.delete(approval.requestId);
1262
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1263
+ return;
1264
+ }
1048
1265
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1049
1266
  if (isAlwaysAllow && !isDeny) {
1050
1267
  this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
@@ -1056,12 +1273,6 @@ export class SessionManager {
1056
1273
  approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
1057
1274
  session.pendingToolApprovals.delete(approval.requestId);
1058
1275
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1059
- // When ExitPlanMode is approved via the PreToolUse hook, immediately clear
1060
- // pending state and emit planning_mode:false. The control_request path may
1061
- // never arrive (or arrive as is_error=true), so this ensures plan mode exits.
1062
- if (approval.toolName === 'ExitPlanMode' && !isDeny) {
1063
- session.claudeProcess?.clearPendingExitPlanMode();
1064
- }
1065
1276
  }
1066
1277
  /**
1067
1278
  * Send an AskUserQuestion control response, mapping the user's answer(s) into
@@ -1132,6 +1343,11 @@ export class SessionManager {
1132
1343
  return Promise.resolve({ allow: true, always: false });
1133
1344
  }
1134
1345
  console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
1346
+ // ExitPlanMode: route through PlanManager state machine for plan-specific
1347
+ // approval UI. The hook blocks until we resolve the promise.
1348
+ if (toolName === 'ExitPlanMode') {
1349
+ return this.handleExitPlanModeApproval(session, sessionId);
1350
+ }
1135
1351
  // Prevent double-gating: if a control_request already created a pending
1136
1352
  // entry for this tool, auto-approve the control_request and let the hook
1137
1353
  // take over as the sole approval gate. This is the reverse of the check
@@ -1167,7 +1383,7 @@ export class SessionManager {
1167
1383
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
1168
1384
  resolve({ allow: false, always: false });
1169
1385
  }
1170
- }, isQuestion || toolName === 'ExitPlanMode' ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions, plan exit & agent children, 1 min for interactive
1386
+ }, isQuestion ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions & agent children, 1 min for interactive
1171
1387
  let promptMsg;
1172
1388
  if (isQuestion) {
1173
1389
  // AskUserQuestion: extract structured questions from toolInput.questions
@@ -1242,6 +1458,66 @@ export class SessionManager {
1242
1458
  }
1243
1459
  });
1244
1460
  }
1461
+ /**
1462
+ * Handle ExitPlanMode approval through PlanManager.
1463
+ * Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
1464
+ * until the user responds. On approve, returns allow:true (the hook will use
1465
+ * the deny-with-approval-message workaround). On deny, returns allow:false.
1466
+ */
1467
+ handleExitPlanModeApproval(session, sessionId) {
1468
+ const reviewId = session.planManager.onExitPlanModeRequested();
1469
+ if (!reviewId) {
1470
+ // Not in planning state — fall through to allow (CLI handles natively)
1471
+ console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
1472
+ return Promise.resolve({ allow: true, always: false });
1473
+ }
1474
+ return new Promise((resolve) => {
1475
+ const timer = { id: null };
1476
+ const wrappedResolve = (result) => {
1477
+ if (timer.id)
1478
+ clearTimeout(timer.id);
1479
+ resolve(result);
1480
+ };
1481
+ // Timeout: auto-deny after 5 minutes to prevent leaked promises
1482
+ timer.id = setTimeout(() => {
1483
+ if (session.pendingToolApprovals.has(reviewId)) {
1484
+ console.log(`[plan-approval] timed out, auto-denying`);
1485
+ session.pendingToolApprovals.delete(reviewId);
1486
+ session.planManager.deny(reviewId);
1487
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
1488
+ resolve({ allow: false, always: false });
1489
+ }
1490
+ }, 300_000);
1491
+ const promptMsg = {
1492
+ type: 'prompt',
1493
+ promptType: 'permission',
1494
+ question: 'Approve plan and start implementation?',
1495
+ options: [
1496
+ { label: 'Approve', value: 'allow' },
1497
+ { label: 'Reject', value: 'deny' },
1498
+ ],
1499
+ toolName: 'ExitPlanMode',
1500
+ requestId: reviewId,
1501
+ };
1502
+ session.pendingToolApprovals.set(reviewId, {
1503
+ resolve: wrappedResolve,
1504
+ toolName: 'ExitPlanMode',
1505
+ toolInput: {},
1506
+ requestId: reviewId,
1507
+ promptMsg,
1508
+ });
1509
+ this.broadcast(session, promptMsg);
1510
+ if (session.clients.size === 0) {
1511
+ this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
1512
+ }
1513
+ for (const listener of this._promptListeners) {
1514
+ try {
1515
+ listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
1516
+ }
1517
+ catch { /* listener error */ }
1518
+ }
1519
+ });
1520
+ }
1245
1521
  /**
1246
1522
  * Check if a tool invocation can be auto-approved without prompting the user.
1247
1523
  * Returns 'registry' if matched by auto-approval rules, 'session' if matched
@@ -1255,7 +1531,7 @@ export class SessionManager {
1255
1531
  if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
1256
1532
  return 'session';
1257
1533
  }
1258
- if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')) {
1534
+ if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'orchestrator')) {
1259
1535
  return 'headless';
1260
1536
  }
1261
1537
  return 'prompt';
@@ -1301,8 +1577,6 @@ export class SessionManager {
1301
1577
  const filePath = String(toolInput.file_path || '');
1302
1578
  return `Allow Read? \`${filePath}\``;
1303
1579
  }
1304
- case 'ExitPlanMode':
1305
- return 'Approve plan and start implementation?';
1306
1580
  default:
1307
1581
  return `Allow ${toolName}?`;
1308
1582
  }
@@ -1359,7 +1633,6 @@ export class SessionManager {
1359
1633
  const session = this.sessions.get(sessionId);
1360
1634
  if (session?.claudeProcess) {
1361
1635
  session._stoppedByUser = true;
1362
- this.clearStallTimer(session);
1363
1636
  if (session._apiRetryTimer)
1364
1637
  clearTimeout(session._apiRetryTimer);
1365
1638
  session.claudeProcess.removeAllListeners();
@@ -1379,7 +1652,6 @@ export class SessionManager {
1379
1652
  return;
1380
1653
  const cp = session.claudeProcess;
1381
1654
  session._stoppedByUser = true;
1382
- this.clearStallTimer(session);
1383
1655
  if (session._apiRetryTimer)
1384
1656
  clearTimeout(session._apiRetryTimer);
1385
1657
  cp.removeAllListeners();
@@ -1396,6 +1668,32 @@ export class SessionManager {
1396
1668
  isRetryableApiError(text) {
1397
1669
  return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
1398
1670
  }
1671
+ /** Extract the concatenated text output from the current turn (after the last 'result' in history). */
1672
+ extractCurrentTurnText(session) {
1673
+ let text = '';
1674
+ for (let i = session.outputHistory.length - 1; i >= 0; i--) {
1675
+ const msg = session.outputHistory[i];
1676
+ if (msg.type === 'result')
1677
+ break;
1678
+ if (msg.type === 'output')
1679
+ text = msg.data + text;
1680
+ }
1681
+ return text;
1682
+ }
1683
+ /** Remove output messages from the current turn in history (after the last 'result'). */
1684
+ stripCurrentTurnOutput(session) {
1685
+ let cutIndex = session.outputHistory.length;
1686
+ for (let i = session.outputHistory.length - 1; i >= 0; i--) {
1687
+ const msg = session.outputHistory[i];
1688
+ if (msg.type === 'result')
1689
+ break;
1690
+ if (msg.type === 'output') {
1691
+ cutIndex = i;
1692
+ }
1693
+ }
1694
+ // Remove output entries from cutIndex onwards (keep non-output entries like tool events)
1695
+ session.outputHistory = session.outputHistory.filter((msg, idx) => idx < cutIndex || msg.type !== 'output');
1696
+ }
1399
1697
  /**
1400
1698
  * Build a condensed text summary of a session's conversation history.
1401
1699
  * Used as context when auto-starting Claude for sessions without a saved
@@ -1459,43 +1757,36 @@ export class SessionManager {
1459
1757
  }
1460
1758
  return `[This session was interrupted by a server restart. Here is the previous conversation for context:]\n${context}\n[End of previous context. The user's new message follows.]`;
1461
1759
  }
1462
- resetStallTimer(session) {
1463
- this.clearStallTimer(session);
1464
- session._stallTimer = setTimeout(() => {
1465
- session._stallTimer = null;
1466
- if (!session.claudeProcess?.isAlive())
1467
- return;
1468
- const msg = {
1469
- type: 'system_message',
1470
- subtype: 'stall',
1471
- text: 'No output for 5 minutes. The process may be stalled.',
1472
- };
1473
- this.addToHistory(session, msg);
1474
- this.broadcast(session, msg);
1475
- }, STALL_TIMEOUT_MS);
1476
- }
1477
- clearStallTimer(session) {
1478
- if (session._stallTimer) {
1479
- clearTimeout(session._stallTimer);
1480
- session._stallTimer = null;
1481
- }
1482
- }
1760
+ /** Max size of a single output chunk in the history buffer. */
1761
+ static MAX_OUTPUT_CHUNK = 50_000; // 50KB
1483
1762
  /**
1484
1763
  * Append a message to a session's output history for replay.
1485
- * Merges consecutive 'output' chunks into a single entry to save space.
1764
+ * Merges consecutive 'output' chunks up to MAX_OUTPUT_CHUNK to save space,
1765
+ * and splits oversized outputs into multiple entries to bound replay cost.
1486
1766
  */
1487
1767
  addToHistory(session, msg) {
1488
1768
  if (msg.type === 'output') {
1489
1769
  const last = session.outputHistory[session.outputHistory.length - 1];
1490
- if (last?.type === 'output' && last.data.length < 100_000) {
1770
+ if (last?.type === 'output' && last.data.length < SessionManager.MAX_OUTPUT_CHUNK) {
1491
1771
  last.data += msg.data;
1492
1772
  this.persistToDiskDebounced();
1493
1773
  return;
1494
1774
  }
1775
+ // Split oversized output into bounded chunks
1776
+ if (msg.data.length > SessionManager.MAX_OUTPUT_CHUNK) {
1777
+ for (let i = 0; i < msg.data.length; i += SessionManager.MAX_OUTPUT_CHUNK) {
1778
+ session.outputHistory.push({ type: 'output', data: msg.data.slice(i, i + SessionManager.MAX_OUTPUT_CHUNK) });
1779
+ }
1780
+ if (session.outputHistory.length > MAX_HISTORY) {
1781
+ session.outputHistory.splice(0, session.outputHistory.length - MAX_HISTORY);
1782
+ }
1783
+ this.persistToDiskDebounced();
1784
+ return;
1785
+ }
1495
1786
  }
1496
1787
  session.outputHistory.push(msg);
1497
1788
  if (session.outputHistory.length > MAX_HISTORY) {
1498
- session.outputHistory = session.outputHistory.slice(-MAX_HISTORY);
1789
+ session.outputHistory.splice(0, session.outputHistory.length - MAX_HISTORY);
1499
1790
  }
1500
1791
  this.persistToDiskDebounced();
1501
1792
  }
@@ -1549,6 +1840,10 @@ export class SessionManager {
1549
1840
  /** Graceful shutdown: complete in-progress tasks, persist state, kill all processes.
1550
1841
  * Returns a promise that resolves once all Claude processes have exited. */
1551
1842
  shutdown() {
1843
+ if (this._idleReaperInterval) {
1844
+ clearInterval(this._idleReaperInterval);
1845
+ this._idleReaperInterval = null;
1846
+ }
1552
1847
  // Complete in-progress tasks for active sessions before persisting.
1553
1848
  // This handles self-deploy: the commit/push task was the last step, and
1554
1849
  // the server restart means it succeeded. Without this, restored sessions
@@ -1571,7 +1866,6 @@ export class SessionManager {
1571
1866
  session.claudeProcess.stop();
1572
1867
  }));
1573
1868
  }
1574
- this.clearStallTimer(session);
1575
1869
  }
1576
1870
  this.archive.shutdown();
1577
1871
  if (exitPromises.length === 0)