codekin 0.5.0 → 0.5.1

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.
@@ -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,8 +37,6 @@ 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). */
@@ -91,6 +90,10 @@ export class SessionManager {
91
90
  rename: (sessionId, newName) => this.rename(sessionId, newName),
92
91
  });
93
92
  this.sessionPersistence.restoreFromDisk();
93
+ // Wire PlanManager events for restored sessions
94
+ for (const session of this.sessions.values()) {
95
+ this.wirePlanManager(session);
96
+ }
94
97
  }
95
98
  // ---------------------------------------------------------------------------
96
99
  // Approval — direct accessor (callers use sessions.approvalManager.xxx)
@@ -143,7 +146,6 @@ export class SessionManager {
143
146
  restartCount: 0,
144
147
  lastRestartAt: null,
145
148
  _stoppedByUser: false,
146
- _stallTimer: null,
147
149
  _wasActiveBeforeRestart: false,
148
150
  _apiRetryCount: 0,
149
151
  _turnCount: 0,
@@ -152,7 +154,9 @@ export class SessionManager {
152
154
  pendingControlRequests: new Map(),
153
155
  pendingToolApprovals: new Map(),
154
156
  _leaveGraceTimer: null,
157
+ planManager: new PlanManager(),
155
158
  };
159
+ this.wirePlanManager(session);
156
160
  this.sessions.set(id, session);
157
161
  this.persistToDisk();
158
162
  this._globalBroadcast?.({ type: 'sessions_updated' });
@@ -327,6 +331,11 @@ export class SessionManager {
327
331
  * The `willRestart` flag indicates whether the session will be auto-restarted. */
328
332
  onSessionExit(listener) {
329
333
  this._exitListeners.push(listener);
334
+ return () => {
335
+ const idx = this._exitListeners.indexOf(listener);
336
+ if (idx >= 0)
337
+ this._exitListeners.splice(idx, 1);
338
+ };
330
339
  }
331
340
  /** Register a listener called when any session emits a prompt (permission request or question). */
332
341
  onSessionPrompt(listener) {
@@ -335,6 +344,11 @@ export class SessionManager {
335
344
  /** Register a listener called when any session completes a turn (result event). */
336
345
  onSessionResult(listener) {
337
346
  this._resultListeners.push(listener);
347
+ return () => {
348
+ const idx = this._resultListeners.indexOf(listener);
349
+ if (idx >= 0)
350
+ this._resultListeners.splice(idx, 1);
351
+ };
338
352
  }
339
353
  get(id) {
340
354
  return this.sessions.get(id);
@@ -488,7 +502,6 @@ export class SessionManager {
488
502
  return false;
489
503
  // Prevent auto-restart when deleting
490
504
  session._stoppedByUser = true;
491
- this.clearStallTimer(session);
492
505
  if (session._apiRetryTimer)
493
506
  clearTimeout(session._apiRetryTimer);
494
507
  if (session._namingTimer)
@@ -603,7 +616,6 @@ export class SessionManager {
603
616
  this.wireClaudeEvents(cp, session, sessionId);
604
617
  cp.start();
605
618
  session.claudeProcess = cp;
606
- this.resetStallTimer(session);
607
619
  this._globalBroadcast?.({ type: 'sessions_updated' });
608
620
  const startMsg = { type: 'claude_started', sessionId };
609
621
  this.addToHistory(session, startMsg);
@@ -622,11 +634,23 @@ export class SessionManager {
622
634
  cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
623
635
  cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
624
636
  cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
625
- cp.on('planning_mode', (active) => { this.broadcastAndHistory(session, { type: 'planning_mode', active }); });
637
+ cp.on('planning_mode', (active) => {
638
+ // Route EnterPlanMode through PlanManager for UI state tracking.
639
+ // ExitPlanMode (active=false) is ignored here — the PreToolUse hook
640
+ // is the enforcement gate, and it calls handleExitPlanModeApproval()
641
+ // which transitions PlanManager to 'reviewing'.
642
+ if (active) {
643
+ session.planManager.onEnterPlanMode();
644
+ }
645
+ // ExitPlanMode stream event intentionally ignored — hook handles it.
646
+ });
626
647
  cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
627
648
  cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
628
649
  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); });
650
+ cp.on('result', (result, isError) => {
651
+ session.planManager.onTurnEnd();
652
+ this.handleClaudeResult(session, sessionId, result, isError);
653
+ });
630
654
  cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
631
655
  cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(session, sessionId, code, signal); });
632
656
  }
@@ -635,6 +659,20 @@ export class SessionManager {
635
659
  this.addToHistory(session, msg);
636
660
  this.broadcast(session, msg);
637
661
  }
662
+ /**
663
+ * Wire PlanManager events for a session.
664
+ * Called once at session creation (not per-process, since PlanManager outlives restarts).
665
+ * Idempotent — guards against double-wiring on restore + restart.
666
+ */
667
+ wirePlanManager(session) {
668
+ if (session._planManagerWired)
669
+ return;
670
+ session._planManagerWired = true;
671
+ const pm = session.planManager;
672
+ pm.on('planning_mode', (active) => {
673
+ this.broadcastAndHistory(session, { type: 'planning_mode', active });
674
+ });
675
+ }
638
676
  onSystemInit(cp, session, model) {
639
677
  session.claudeSessionId = cp.getSessionId();
640
678
  // Only show model message on first init or when model actually changes
@@ -644,30 +682,24 @@ export class SessionManager {
644
682
  }
645
683
  }
646
684
  onTextEvent(session, sessionId, text) {
647
- this.resetStallTimer(session);
648
685
  this.broadcastAndHistory(session, { type: 'output', data: text });
649
686
  if (session.name.startsWith('hub:') && !session._namingTimer) {
650
687
  this.scheduleSessionNaming(sessionId);
651
688
  }
652
689
  }
653
690
  onThinkingEvent(session, summary) {
654
- this.resetStallTimer(session);
655
691
  this.broadcast(session, { type: 'thinking', summary });
656
692
  }
657
693
  onToolOutputEvent(session, content, isError) {
658
- this.resetStallTimer(session);
659
694
  this.broadcastAndHistory(session, { type: 'tool_output', content, isError });
660
695
  }
661
696
  onImageEvent(session, base64, mediaType) {
662
- this.resetStallTimer(session);
663
697
  this.broadcastAndHistory(session, { type: 'image', base64, mediaType });
664
698
  }
665
699
  onToolActiveEvent(session, toolName, toolInput) {
666
- this.resetStallTimer(session);
667
700
  this.broadcastAndHistory(session, { type: 'tool_active', toolName, toolInput });
668
701
  }
669
702
  onToolDoneEvent(session, toolName, summary) {
670
- this.resetStallTimer(session);
671
703
  this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
672
704
  }
673
705
  onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
@@ -806,6 +838,16 @@ export class SessionManager {
806
838
  this.broadcast(session, msg);
807
839
  }
808
840
  }
841
+ // Suppress noise from orchestrator/agent sessions: if the entire turn's
842
+ // text output is a short, low-value phrase, strip it from history so it
843
+ // doesn't pollute the chat or replay on rejoin.
844
+ if ((session.source === 'orchestrator' || session.source === 'agent') && !isError) {
845
+ const turnText = this.extractCurrentTurnText(session);
846
+ if (turnText && turnText.length < 80 && /^(no response requested|please approve|nothing to do|no action needed|acknowledged)[.!]?$/i.test(turnText.trim())) {
847
+ this.stripCurrentTurnOutput(session);
848
+ console.log(`[noise-filter] suppressed orchestrator noise: "${turnText.trim().slice(0, 60)}"`);
849
+ }
850
+ }
809
851
  const resultMsg = { type: 'result' };
810
852
  this.addToHistory(session, resultMsg);
811
853
  this.broadcast(session, resultMsg);
@@ -835,7 +877,7 @@ export class SessionManager {
835
877
  handleClaudeExit(session, sessionId, code, signal) {
836
878
  session.claudeProcess = null;
837
879
  session.isProcessing = false;
838
- this.clearStallTimer(session);
880
+ session.planManager.reset();
839
881
  this._globalBroadcast?.({ type: 'sessions_updated' });
840
882
  const action = evaluateRestart({
841
883
  restartCount: session.restartCount,
@@ -965,6 +1007,8 @@ export class SessionManager {
965
1007
  const session = this.sessions.get(sessionId);
966
1008
  if (!session)
967
1009
  return;
1010
+ // ExitPlanMode approvals are handled through the normal pendingToolApprovals
1011
+ // path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
968
1012
  // Check for pending tool approval from PreToolUse hook
969
1013
  if (!requestId) {
970
1014
  const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
@@ -1045,6 +1089,27 @@ export class SessionManager {
1045
1089
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1046
1090
  return;
1047
1091
  }
1092
+ // ExitPlanMode: route through PlanManager for state tracking.
1093
+ // The hook will convert allow→deny-with-approval-message (CLI workaround).
1094
+ if (approval.toolName === 'ExitPlanMode') {
1095
+ const first = Array.isArray(value) ? value[0] : value;
1096
+ const isDeny = first === 'deny';
1097
+ if (isDeny) {
1098
+ // Extract feedback text if present (value may be ['deny', 'feedback text'])
1099
+ const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
1100
+ const reason = session.planManager.deny(approval.requestId, feedback);
1101
+ console.log(`[plan-approval] denied: ${reason}`);
1102
+ approval.resolve({ allow: false, always: false });
1103
+ }
1104
+ else {
1105
+ session.planManager.approve(approval.requestId);
1106
+ console.log(`[plan-approval] approved`);
1107
+ approval.resolve({ allow: true, always: false });
1108
+ }
1109
+ session.pendingToolApprovals.delete(approval.requestId);
1110
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1111
+ return;
1112
+ }
1048
1113
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1049
1114
  if (isAlwaysAllow && !isDeny) {
1050
1115
  this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
@@ -1056,12 +1121,6 @@ export class SessionManager {
1056
1121
  approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
1057
1122
  session.pendingToolApprovals.delete(approval.requestId);
1058
1123
  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
1124
  }
1066
1125
  /**
1067
1126
  * Send an AskUserQuestion control response, mapping the user's answer(s) into
@@ -1132,6 +1191,11 @@ export class SessionManager {
1132
1191
  return Promise.resolve({ allow: true, always: false });
1133
1192
  }
1134
1193
  console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
1194
+ // ExitPlanMode: route through PlanManager state machine for plan-specific
1195
+ // approval UI. The hook blocks until we resolve the promise.
1196
+ if (toolName === 'ExitPlanMode') {
1197
+ return this.handleExitPlanModeApproval(session, sessionId);
1198
+ }
1135
1199
  // Prevent double-gating: if a control_request already created a pending
1136
1200
  // entry for this tool, auto-approve the control_request and let the hook
1137
1201
  // take over as the sole approval gate. This is the reverse of the check
@@ -1167,7 +1231,7 @@ export class SessionManager {
1167
1231
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
1168
1232
  resolve({ allow: false, always: false });
1169
1233
  }
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
1234
+ }, isQuestion ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions & agent children, 1 min for interactive
1171
1235
  let promptMsg;
1172
1236
  if (isQuestion) {
1173
1237
  // AskUserQuestion: extract structured questions from toolInput.questions
@@ -1242,6 +1306,66 @@ export class SessionManager {
1242
1306
  }
1243
1307
  });
1244
1308
  }
1309
+ /**
1310
+ * Handle ExitPlanMode approval through PlanManager.
1311
+ * Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
1312
+ * until the user responds. On approve, returns allow:true (the hook will use
1313
+ * the deny-with-approval-message workaround). On deny, returns allow:false.
1314
+ */
1315
+ handleExitPlanModeApproval(session, sessionId) {
1316
+ const reviewId = session.planManager.onExitPlanModeRequested();
1317
+ if (!reviewId) {
1318
+ // Not in planning state — fall through to allow (CLI handles natively)
1319
+ console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
1320
+ return Promise.resolve({ allow: true, always: false });
1321
+ }
1322
+ return new Promise((resolve) => {
1323
+ const timer = { id: null };
1324
+ const wrappedResolve = (result) => {
1325
+ if (timer.id)
1326
+ clearTimeout(timer.id);
1327
+ resolve(result);
1328
+ };
1329
+ // Timeout: auto-deny after 5 minutes to prevent leaked promises
1330
+ timer.id = setTimeout(() => {
1331
+ if (session.pendingToolApprovals.has(reviewId)) {
1332
+ console.log(`[plan-approval] timed out, auto-denying`);
1333
+ session.pendingToolApprovals.delete(reviewId);
1334
+ session.planManager.deny(reviewId);
1335
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
1336
+ resolve({ allow: false, always: false });
1337
+ }
1338
+ }, 300_000);
1339
+ const promptMsg = {
1340
+ type: 'prompt',
1341
+ promptType: 'permission',
1342
+ question: 'Approve plan and start implementation?',
1343
+ options: [
1344
+ { label: 'Approve', value: 'allow' },
1345
+ { label: 'Reject', value: 'deny' },
1346
+ ],
1347
+ toolName: 'ExitPlanMode',
1348
+ requestId: reviewId,
1349
+ };
1350
+ session.pendingToolApprovals.set(reviewId, {
1351
+ resolve: wrappedResolve,
1352
+ toolName: 'ExitPlanMode',
1353
+ toolInput: {},
1354
+ requestId: reviewId,
1355
+ promptMsg,
1356
+ });
1357
+ this.broadcast(session, promptMsg);
1358
+ if (session.clients.size === 0) {
1359
+ this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
1360
+ }
1361
+ for (const listener of this._promptListeners) {
1362
+ try {
1363
+ listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
1364
+ }
1365
+ catch { /* listener error */ }
1366
+ }
1367
+ });
1368
+ }
1245
1369
  /**
1246
1370
  * Check if a tool invocation can be auto-approved without prompting the user.
1247
1371
  * Returns 'registry' if matched by auto-approval rules, 'session' if matched
@@ -1301,8 +1425,6 @@ export class SessionManager {
1301
1425
  const filePath = String(toolInput.file_path || '');
1302
1426
  return `Allow Read? \`${filePath}\``;
1303
1427
  }
1304
- case 'ExitPlanMode':
1305
- return 'Approve plan and start implementation?';
1306
1428
  default:
1307
1429
  return `Allow ${toolName}?`;
1308
1430
  }
@@ -1359,7 +1481,6 @@ export class SessionManager {
1359
1481
  const session = this.sessions.get(sessionId);
1360
1482
  if (session?.claudeProcess) {
1361
1483
  session._stoppedByUser = true;
1362
- this.clearStallTimer(session);
1363
1484
  if (session._apiRetryTimer)
1364
1485
  clearTimeout(session._apiRetryTimer);
1365
1486
  session.claudeProcess.removeAllListeners();
@@ -1379,7 +1500,6 @@ export class SessionManager {
1379
1500
  return;
1380
1501
  const cp = session.claudeProcess;
1381
1502
  session._stoppedByUser = true;
1382
- this.clearStallTimer(session);
1383
1503
  if (session._apiRetryTimer)
1384
1504
  clearTimeout(session._apiRetryTimer);
1385
1505
  cp.removeAllListeners();
@@ -1396,6 +1516,32 @@ export class SessionManager {
1396
1516
  isRetryableApiError(text) {
1397
1517
  return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
1398
1518
  }
1519
+ /** Extract the concatenated text output from the current turn (after the last 'result' in history). */
1520
+ extractCurrentTurnText(session) {
1521
+ let text = '';
1522
+ for (let i = session.outputHistory.length - 1; i >= 0; i--) {
1523
+ const msg = session.outputHistory[i];
1524
+ if (msg.type === 'result')
1525
+ break;
1526
+ if (msg.type === 'output')
1527
+ text = msg.data + text;
1528
+ }
1529
+ return text;
1530
+ }
1531
+ /** Remove output messages from the current turn in history (after the last 'result'). */
1532
+ stripCurrentTurnOutput(session) {
1533
+ let cutIndex = session.outputHistory.length;
1534
+ for (let i = session.outputHistory.length - 1; i >= 0; i--) {
1535
+ const msg = session.outputHistory[i];
1536
+ if (msg.type === 'result')
1537
+ break;
1538
+ if (msg.type === 'output') {
1539
+ cutIndex = i;
1540
+ }
1541
+ }
1542
+ // Remove output entries from cutIndex onwards (keep non-output entries like tool events)
1543
+ session.outputHistory = session.outputHistory.filter((msg, idx) => idx < cutIndex || msg.type !== 'output');
1544
+ }
1399
1545
  /**
1400
1546
  * Build a condensed text summary of a session's conversation history.
1401
1547
  * Used as context when auto-starting Claude for sessions without a saved
@@ -1459,27 +1605,6 @@ export class SessionManager {
1459
1605
  }
1460
1606
  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
1607
  }
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
- }
1483
1608
  /**
1484
1609
  * Append a message to a session's output history for replay.
1485
1610
  * Merges consecutive 'output' chunks into a single entry to save space.
@@ -1571,7 +1696,6 @@ export class SessionManager {
1571
1696
  session.claudeProcess.stop();
1572
1697
  }));
1573
1698
  }
1574
- this.clearStallTimer(session);
1575
1699
  }
1576
1700
  this.archive.shutdown();
1577
1701
  if (exitPromises.length === 0)