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.
- package/dist/assets/index-84JYN21S.js +178 -0
- package/dist/assets/index-C0Iuc3iT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +30 -28
- package/server/dist/approval-manager.d.ts +2 -0
- package/server/dist/approval-manager.js +4 -1
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/claude-process.d.ts +26 -26
- package/server/dist/claude-process.js +76 -137
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/native-permissions.js +1 -1
- package/server/dist/native-permissions.js.map +1 -1
- package/server/dist/orchestrator-children.js +16 -10
- package/server/dist/orchestrator-children.js.map +1 -1
- package/server/dist/orchestrator-manager.js +4 -1
- package/server/dist/orchestrator-manager.js.map +1 -1
- package/server/dist/orchestrator-reports.js +1 -1
- package/server/dist/orchestrator-routes.d.ts +3 -1
- package/server/dist/orchestrator-routes.js +3 -3
- package/server/dist/orchestrator-routes.js.map +1 -1
- package/server/dist/plan-manager.d.ts +74 -0
- package/server/dist/plan-manager.js +121 -0
- package/server/dist/plan-manager.js.map +1 -0
- package/server/dist/session-archive.js +2 -2
- package/server/dist/session-archive.js.map +1 -1
- package/server/dist/session-manager.d.ts +56 -5
- package/server/dist/session-manager.js +389 -95
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-persistence.js +4 -1
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +15 -4
- package/server/dist/types.js +1 -1
- package/server/dist/types.js.map +1 -1
- package/server/dist/upload-routes.js +3 -2
- package/server/dist/upload-routes.js.map +1 -1
- package/server/dist/webhook-config.js +9 -0
- package/server/dist/webhook-config.js.map +1 -1
- package/server/dist/webhook-handler.js +13 -0
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/webhook-types.d.ts +1 -0
- package/server/dist/workflow-loader.js +21 -21
- package/server/dist/workflow-loader.js.map +1 -1
- package/server/dist/ws-server.js +4 -0
- package/server/dist/ws-server.js.map +1 -1
- package/dist/assets/index-BwKZeT4V.css +0 -1
- 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.
|
|
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.
|
|
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,
|
|
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) => {
|
|
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) => {
|
|
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
|
-
//
|
|
765
|
-
if (isError && session
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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: '
|
|
794
|
-
text: `
|
|
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.
|
|
797
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1463
|
-
|
|
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
|
|
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 <
|
|
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
|
|
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)
|