@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.2",
3
+ "version": "1.0.18-rc.3",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.2",
51
- "@adhdev/session-host-core": "1.0.18-rc.2",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.3",
51
+ "@adhdev/session-host-core": "1.0.18-rc.3",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -86,6 +86,18 @@ interface IdleFinishCandidate {
86
86
  assistantLength: number;
87
87
  }
88
88
 
89
+ /**
90
+ * Minimal shape computeApprovalContentSignature / isStaleResolvedApproval
91
+ * actually need — deliberately narrower than the full CliBufferSnapshot so
92
+ * callers outside the settled-eval loop (e.g. the adapter's startup-gate
93
+ * modal parse in getStatus/getDebugState) can supply just these two fields
94
+ * without having to fabricate an entire snapshot.
95
+ */
96
+ interface ApprovalSignatureSnapshot {
97
+ screenText?: string;
98
+ accumulatedBuffer?: string;
99
+ }
100
+
89
101
  interface SettledEvalContext {
90
102
  now: number;
91
103
  modal: { message: string; buttons: string[] } | null;
@@ -155,6 +167,22 @@ export class CliStateEngine {
155
167
  // ── Approval ─────────────────────────────────────
156
168
  lastApprovalResolvedAt = 0;
157
169
  lastResolvedModalMessage = '';
170
+ /**
171
+ * Normalized approval-context signature captured at resolve time (see
172
+ * `computeApprovalContentSignature`) — the screen text with blank-line
173
+ * padding and any manifest-declared chrome (transcriptPty.chromePatterns,
174
+ * spinner.patterns) stripped out. Used by applyWaitingApproval's
175
+ * isStaleResolvedRepaint check to tell "the same already-answered modal,
176
+ * re-parsed from a screen that has not meaningfully changed" apart from
177
+ * "a genuinely new approval" — content-based, not time-based, because
178
+ * ordinary TUI chrome (status bar, context meter, blank-line repaint)
179
+ * keeps producing fresh PTY bytes on an otherwise-unchanged screen and
180
+ * defeats any output-timestamp discriminator within a few hundred ms.
181
+ * Cleared whenever a turn starts or the session tears down (see
182
+ * onTurnStarted / resetActiveTurnState / onPtyExit) so a next-turn
183
+ * approval is never compared against stale prior-turn state.
184
+ */
185
+ lastApprovalResolvedContentSignature = '';
158
186
  /**
159
187
  * Monotonic counter bumped every time the FSM *enters* waiting_approval
160
188
  * with a freshly captured modal (see `applyWaitingApproval`). It is the
@@ -304,12 +332,46 @@ export class CliStateEngine {
304
332
  // anchor its window on dispatch time rather than completion time.
305
333
  this.currentTurnStartedAt = Date.now();
306
334
  this.responseEpoch += 1;
335
+ // A new turn's own prompt echo is real new content, so the stale-repaint
336
+ // guard's signature would naturally drift anyway — but clear the
337
+ // resolve-time bookkeeping explicitly here too, so a next-turn approval
338
+ // that happens to share message text with a previous turn's already-
339
+ // resolved one is never compared against stale prior-turn state.
340
+ this.clearApprovalResolutionMemory();
341
+ // (fix: kimi K3 send→idle-looking→generating lag / missed-generating on
342
+ // fast tool turns) For transcriptAuthority:'provider' providers (kimi,
343
+ // and other native-transcript sources), the PTY-scanned parser always
344
+ // returns messages:[] — the provider owns the transcript, not the PTY —
345
+ // so evaluateSettled's shouldHoldGenerating fast-path exception
346
+ // (hasFinalCurrentTurnAssistant) can never release early and
347
+ // recent_activity_hold ends up carrying the ENTIRE "is this turn still
348
+ // generating" signal for these providers regardless of what the spinner
349
+ // script does. That fallback only fires once a settle tick actually
350
+ // runs, which needs PTY output to schedule — for a model that "thinks"
351
+ // silently for many seconds before its first repaint (observed 12-20s
352
+ // for Kimi K3's default "high" effort), the dashboard looks idle for
353
+ // that whole window even though the turn was already accepted. Worse,
354
+ // a turn that fully completes (tool calls + reply) within a single
355
+ // settle debounce window can go straight idle→idle with no visible
356
+ // generating state at all (observed live with a yolo tool-use turn).
357
+ // onTurnStarted is the authoritative "a turn was just submitted" signal
358
+ // — promote to generating immediately instead of waiting on PTY-driven
359
+ // detection. This does not weaken completion detection: applyIdle /
360
+ // finishResponse (authoritative settled evidence) still own the actual
361
+ // idle transition, unchanged. Scoped to transcriptAuthority:'provider'
362
+ // only, so PTY-authoritative providers (whose spinner/settled parsing
363
+ // already drives generating promptly) are unaffected.
364
+ if (this.provider.transcriptAuthority === 'provider' && this.currentStatus !== 'waiting_approval') {
365
+ this.setStatus('generating', 'turn_started');
366
+ this.callbacks.onStatusChange();
367
+ }
307
368
  }
308
369
 
309
370
  /** Called when PTY exits */
310
371
  onPtyExit(): void {
311
372
  this.clearAllTimers();
312
373
  this.setStatus('stopped', 'pty_exit');
374
+ this.clearApprovalResolutionMemory();
313
375
  }
314
376
 
315
377
  /** Called when adapter starts up successfully */
@@ -382,6 +444,11 @@ export class CliStateEngine {
382
444
  this.activeModal = null;
383
445
  this.lastApprovalResolvedAt = Date.now();
384
446
  this.lastResolvedModalMessage = currentModalMessage;
447
+ // Snapshot the chrome-stripped screen at the moment of resolve — the
448
+ // baseline applyWaitingApproval's isStaleResolvedRepaint check compares
449
+ // against to tell a stale re-parse of THIS modal apart from a
450
+ // genuinely new one, regardless of how much wall-clock time passes.
451
+ this.lastApprovalResolvedContentSignature = this.computeApprovalContentSignature(snap);
385
452
  this.lastResolvedEntrySeq = this.approvalEntrySeq;
386
453
  this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
387
454
  if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
@@ -499,6 +566,7 @@ export class CliStateEngine {
499
566
  this.pendingScriptStatusSince = 0;
500
567
  this.approvalResumeDeferSince = 0;
501
568
  this.approvalResumeDeferEpoch = -1;
569
+ this.clearApprovalResolutionMemory();
502
570
  }
503
571
 
504
572
  clearIdleFinishCandidate(reason: string): void {
@@ -679,7 +747,9 @@ export class CliStateEngine {
679
747
  if (!status) return;
680
748
 
681
749
  const prevStatus = this.currentStatus;
682
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
750
+ const ctx: SettledEvalContext = {
751
+ now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus,
752
+ };
683
753
 
684
754
  if (!this.applyPendingScriptStatusDebounce(ctx)) return;
685
755
 
@@ -743,7 +813,7 @@ export class CliStateEngine {
743
813
  this.applyError(ctx, session);
744
814
  return;
745
815
  }
746
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
816
+ if (status === 'waiting_approval') { this.applyWaitingApproval(ctx, snap); return; }
747
817
  if (status === 'generating') { this.applyGenerating(ctx); return; }
748
818
  if (status === 'idle') { this.applyIdle(ctx, snap, now); }
749
819
  }
@@ -801,7 +871,7 @@ export class CliStateEngine {
801
871
  this.callbacks.onStatusChange();
802
872
  }
803
873
 
804
- private applyWaitingApproval(ctx: SettledEvalContext): void {
874
+ private applyWaitingApproval(ctx: SettledEvalContext, snap: CliBufferSnapshot): void {
805
875
  const { modal } = ctx;
806
876
  this.clearIdleFinishCandidate('waiting_approval');
807
877
  const inCooldown = this.lastApprovalResolvedAt
@@ -892,6 +962,51 @@ export class CliStateEngine {
892
962
  }
893
963
  return;
894
964
  }
965
+ // (fix: kimi stale-approval re-latch, observed live on kimi-code
966
+ // v0.28.1/K3) A freshly-*parsed* modal is not proof the CLI is
967
+ // presenting it right now — parseApproval scans an accumulated
968
+ // raw-output window (recentOutputBuffer / window-around-question
969
+ // scope), which can still contain the text of an approval that was
970
+ // ALREADY resolved a moment ago and re-surface it on the very next
971
+ // settle pass, even though resolveModal() already wrote the key and
972
+ // cleared activeModal. Reproduced live: after approving a tool call,
973
+ // the FSM re-latched `waiting_approval` on the identical
974
+ // already-answered question with NO further genuinely new content
975
+ // ever arriving afterward — the dashboard stayed wedged on "waiting
976
+ // for approval" until the 5-minute maxResponse watchdog forced a
977
+ // recheck.
978
+ //
979
+ // An output-TIMESTAMP discriminator ("has any PTY byte arrived since
980
+ // the resolve") was tried first and found insufficient: a live
981
+ // standalone repro showed kimi's own idle-screen chrome (status bar,
982
+ // context meter, blank-line repaint) advances lastNonEmptyOutputAt
983
+ // within ~300ms of the resolve even though NOTHING approval-relevant
984
+ // changed, so the guard stopped protecting almost immediately instead
985
+ // of for as long as the staleness actually persisted (observed 30s+).
986
+ //
987
+ // Reject a recapture only when ALL of: (a) this is the first capture
988
+ // since the last resolve (`!this.activeModal` — a genuinely repeated
989
+ // approval re-enters this branch too, since resolveModal always nulls
990
+ // activeModal), (b) the message text matches the one we just
991
+ // resolved, AND (c) the chrome-stripped approval-context signature
992
+ // (computeApprovalContentSignature — screen text with blank-line
993
+ // padding and manifest-declared chrome removed) is UNCHANGED from
994
+ // the signature captured at resolve time. (c) is the load-bearing,
995
+ // content-based condition, deliberately not time-bounded: it keeps
996
+ // rejecting for as long as nothing approval-relevant changes,
997
+ // regardless of how many chrome-only repaints occur or how much
998
+ // wall-clock time passes, and it stops rejecting the instant real
999
+ // new content (tool output, a fresh conversational turn) appears.
1000
+ // This also keeps consecutive-approvals-with-identical-text (e.g.
1001
+ // two back-to-back "Allow Bash command?" prompts) working correctly:
1002
+ // a real follow-up approval necessarily means the CLI produced real
1003
+ // new output first (running the previous tool, then asking again),
1004
+ // which changes the signature regardless of shared message text.
1005
+ const isStaleResolvedRepaint = !this.activeModal && this.isStaleResolvedApproval(modal, snap);
1006
+ if (isStaleResolvedRepaint) {
1007
+ LOG.debug('CLI', `[${this.provider.type}] ignoring stale re-parsed approval matching the just-resolved modal (approval-context signature unchanged)`);
1008
+ return;
1009
+ }
895
1010
  this.modalLostAt = 0;
896
1011
  this.isWaitingForResponse = true;
897
1012
  this.setStatus('waiting_approval', 'script_detect');
@@ -1256,6 +1371,92 @@ export class CliStateEngine {
1256
1371
 
1257
1372
  // ─── Helpers ────────────────────────────────────────────────────────────
1258
1373
 
1374
+ /**
1375
+ * Derive a stable approval-context signature from the current screen,
1376
+ * with blank-line padding and any manifest-declared chrome stripped out.
1377
+ *
1378
+ * Generic by design — no kimi- or provider-specific hardcoding: it reads
1379
+ * whatever `tui.transcriptPty.chromePatterns` and `tui.spinner.patterns`
1380
+ * the ACTIVE provider's own manifest already declares (present on any
1381
+ * declarative-TUI provider; simply absent/empty for scripted providers,
1382
+ * in which case this degrades to blank-line stripping only — never worse
1383
+ * than comparing the raw screen). Those pattern lists exist precisely to
1384
+ * name "known volatile repaint noise" (status bar, context meter, spinner
1385
+ * ticks, banners) — reusing them here means the SAME declared knowledge
1386
+ * that governs transcript-chrome stripping also governs staleness
1387
+ * detection, instead of re-encoding provider knowledge into daemon-core.
1388
+ *
1389
+ * Deliberately NOT a hash of the whole screen/buffer: an ordinary TUI
1390
+ * repaints its footer/status/context-meter chrome continuously even while
1391
+ * genuinely idle, so a raw whole-screen or whole-buffer fingerprint (or a
1392
+ * mere "did any bytes arrive" timestamp) changes on every repaint tick
1393
+ * regardless of whether anything approval-relevant actually happened.
1394
+ * Stripping the declared chrome first yields a signature that only
1395
+ * changes when the surrounding conversation/tool-output content itself
1396
+ * changes — exactly the discriminator applyWaitingApproval's
1397
+ * isStaleResolvedRepaint check needs.
1398
+ */
1399
+ private computeApprovalContentSignature(snap: ApprovalSignatureSnapshot): string {
1400
+ const screenText = snap.screenText || snap.accumulatedBuffer || '';
1401
+ if (!screenText) return '';
1402
+ const tui = (this.provider as { tui?: Record<string, any> }).tui;
1403
+ const patternSpecs: Array<{ regex?: unknown; flags?: unknown }> = [
1404
+ ...(Array.isArray(tui?.transcriptPty?.chromePatterns) ? tui!.transcriptPty.chromePatterns : []),
1405
+ ...(Array.isArray(tui?.spinner?.patterns) ? tui!.spinner.patterns : []),
1406
+ ];
1407
+ const chromeRegexes: RegExp[] = [];
1408
+ for (const spec of patternSpecs) {
1409
+ if (spec && typeof spec.regex === 'string') {
1410
+ try {
1411
+ chromeRegexes.push(new RegExp(spec.regex, typeof spec.flags === 'string' ? spec.flags : ''));
1412
+ } catch {
1413
+ // Ignore an unparseable manifest regex — signature just skips that filter.
1414
+ }
1415
+ }
1416
+ }
1417
+ const kept: string[] = [];
1418
+ for (const rawLine of screenText.split('\n')) {
1419
+ const line = rawLine.trim();
1420
+ if (!line) continue; // strip blank-line repaint padding
1421
+ if (chromeRegexes.some((re) => re.test(line))) continue; // strip declared chrome
1422
+ kept.push(line);
1423
+ }
1424
+ return kept.join('\n');
1425
+ }
1426
+
1427
+ /**
1428
+ * True when `modal` is a stale re-parse of an already-resolved approval:
1429
+ * same message text, and the chrome-stripped approval-context signature
1430
+ * of `snap` is unchanged from the signature captured at resolve time.
1431
+ *
1432
+ * Public and reused verbatim by BOTH the settled-eval capture path
1433
+ * (applyWaitingApproval, below) and any OUTSIDE re-parse the adapter
1434
+ * performs independently of the settle loop — e.g. provider-cli-adapter's
1435
+ * getStatus()/getDebugState() startup-gate modal detection, which reads
1436
+ * `recentOutputBuffer` directly while `startupParseGate` is open and can
1437
+ * re-surface the same already-resolved modal before the gate closes.
1438
+ * Centralizing the discriminator here means there is exactly ONE
1439
+ * definition of "stale" for the whole session — no divergent duplicate
1440
+ * heuristic re-implemented per call site.
1441
+ */
1442
+ isStaleResolvedApproval(modal: { message: string; buttons: string[] } | null, snap: ApprovalSignatureSnapshot): boolean {
1443
+ if (!modal) return false;
1444
+ const normalizedMessage = typeof modal.message === 'string' ? modal.message.trim() : '';
1445
+ if (!normalizedMessage) return false;
1446
+ return this.lastApprovalResolvedAt > 0
1447
+ && normalizedMessage === this.lastResolvedModalMessage
1448
+ && this.computeApprovalContentSignature(snap) === this.lastApprovalResolvedContentSignature;
1449
+ }
1450
+
1451
+ /** Clear all resolve-time approval bookkeeping (message, timestamp, content
1452
+ * signature) — called at turn/session boundaries so a next-turn or
1453
+ * next-session approval is never compared against stale prior state. */
1454
+ private clearApprovalResolutionMemory(): void {
1455
+ this.lastApprovalResolvedAt = 0;
1456
+ this.lastResolvedModalMessage = '';
1457
+ this.lastApprovalResolvedContentSignature = '';
1458
+ }
1459
+
1259
1460
  /**
1260
1461
  * Schedule one more settled evaluation while pinned to `waiting_approval`
1261
1462
  * with no actionable modal. The settled FSM normally only re-runs on new
@@ -707,8 +707,10 @@ export class ProviderCliAdapter implements CliAdapter {
707
707
  }
708
708
  });
709
709
 
710
- this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
711
- LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
710
+ this.ptyProcess.onExit(({ exitCode, signal }: { exitCode: number | null; signal?: number | null }) => {
711
+ // Preserve the unknown case: a null exitCode (signal-terminated or
712
+ // otherwise unreported) is logged as "unknown", never as exit 0.
713
+ LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode === null || exitCode === undefined ? 'unknown' : exitCode}${signal ? ` (signal ${signal})` : ''}`);
712
714
  this.flushPendingOutputParse();
713
715
  this.ptyProcess = null;
714
716
  this.engine.onPtyExit();
@@ -1072,7 +1074,19 @@ export class ProviderCliAdapter implements CliAdapter {
1072
1074
 
1073
1075
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1074
1076
  const allowParse = options.allowParse !== false;
1075
- const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1077
+ let startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1078
+ // (fix: kimi startup-gate stale-approval bypass) startupParseGate can
1079
+ // still be open by the time a real approval is requested AND resolved
1080
+ // — this ad-hoc parse of recentOutputBuffer runs independently of the
1081
+ // settled-eval loop and previously had no staleness protection at all,
1082
+ // so it kept re-surfacing the identical already-resolved modal for as
1083
+ // long as its text remained anywhere in the rolling buffer, bypassing
1084
+ // engine.activeModal (which resolveModal() had already correctly
1085
+ // cleared). Reuse the SAME discriminator the settle loop uses instead
1086
+ // of inventing a second one here.
1087
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1088
+ startupModal = null;
1089
+ }
1076
1090
  const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
1077
1091
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1078
1092
  : null;
@@ -1179,7 +1193,20 @@ export class ProviderCliAdapter implements CliAdapter {
1179
1193
  // and let a later evaluate find the complete modal.
1180
1194
  const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
1181
1195
  && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
1182
- if (liveModal && buttonsOk) {
1196
+ // (fix: kimi live-detect stale-approval bypass) This fallback is
1197
+ // NOT gated by startupParseGate — it runs on every getStatus()
1198
+ // call (including the periodic background status heartbeat)
1199
+ // for as long as isWaitingForResponse stays true, which for a
1200
+ // provider that keeps "thinking" after the approved tool call
1201
+ // can be the whole rest of the turn. Its own unguarded re-parse
1202
+ // of recentOutputBuffer/terminalScreen previously kept
1203
+ // re-writing engine.activeModal directly — bypassing setStatus
1204
+ // (so rawStatus never even flipped) and bypassing
1205
+ // applyWaitingApproval's staleness guard entirely, silently
1206
+ // re-corrupting the engine's own state moments after
1207
+ // resolveModal() had correctly cleared it. Reuse the same
1208
+ // engine-owned discriminator here too.
1209
+ if (liveModal && buttonsOk && !this.engine.isStaleResolvedApproval(liveModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1183
1210
  effectiveModal = liveModal;
1184
1211
  if (!this.engine.activeModal) this.engine.activeModal = liveModal;
1185
1212
  }
@@ -2474,7 +2501,14 @@ export class ProviderCliAdapter implements CliAdapter {
2474
2501
 
2475
2502
  getDebugState(): Record<string, any> {
2476
2503
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2477
- const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2504
+ let startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2505
+ // (fix: kimi startup-gate stale-approval bypass) See the matching
2506
+ // comment in getStatus() — this ad-hoc startup-gate parse bypassed
2507
+ // engine.activeModal's staleness protection entirely. Reuse the same
2508
+ // engine-owned discriminator rather than duplicating it here.
2509
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
2510
+ startupModal = null;
2511
+ }
2478
2512
  const startupDetectedStatus = this.startupParseGate && !startupModal
2479
2513
  ? this.runDetectStatus(this.recentOutputBuffer || screenText)
2480
2514
  : null;
@@ -37,7 +37,8 @@ export interface PtyRuntimeTransport {
37
37
  getMetadata?(): PtyRuntimeMetadata | null;
38
38
  onData(callback: (data: string) => void): void;
39
39
  onExit(callback: (info: {
40
- exitCode: number;
40
+ exitCode: number | null;
41
+ signal?: number | null;
41
42
  }) => void): void;
42
43
  }
43
44
  export interface PtyTransportFactory {
@@ -62,7 +62,7 @@ export interface PtyRuntimeTransport {
62
62
  updateMeta?(meta: Record<string, unknown>, replace?: boolean): void;
63
63
  getMetadata?(): PtyRuntimeMetadata | null;
64
64
  onData(callback: (data: string) => void): void;
65
- onExit(callback: (info: { exitCode: number }) => void): void;
65
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void;
66
66
  }
67
67
 
68
68
  export interface PtyTransportFactory {
@@ -42,7 +42,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
42
42
 
43
43
  private readonly client: SessionHostClient;
44
44
  private readonly dataCallbacks = new Set<(data: string) => void>();
45
- private readonly exitCallbacks = new Set<(info: { exitCode: number }) => void>();
45
+ private readonly exitCallbacks = new Set<(info: { exitCode: number | null; signal?: number | null }) => void>();
46
46
  private readonly pendingOutput: string[] = [];
47
47
  private operationChain = Promise.resolve();
48
48
  private unsubscribe: (() => void) | null = null;
@@ -79,7 +79,7 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
79
79
  }
80
80
  }
81
81
 
82
- onExit(callback: (info: { exitCode: number }) => void): void {
82
+ onExit(callback: (info: { exitCode: number | null; signal?: number | null }) => void): void {
83
83
  this.exitCallbacks.add(callback);
84
84
  }
85
85
 
@@ -361,8 +361,13 @@ class SessionHostRuntimeTransport implements PtyRuntimeTransport {
361
361
  return;
362
362
  }
363
363
  if (event.type === 'session_exit') {
364
+ // Preserve the nullable/unknown exitCode and signal exactly as the
365
+ // session host reported them — never collapse null to 0, which would
366
+ // make a signal-terminated process indistinguishable from a clean exit.
367
+ const exitCode = typeof event.exitCode === 'number' ? event.exitCode : null;
368
+ const signal = typeof event.signal === 'number' ? event.signal : null;
364
369
  for (const callback of this.exitCallbacks) {
365
- callback({ exitCode: event.exitCode ?? 0 });
370
+ callback({ exitCode, signal });
366
371
  }
367
372
  void this.closeClient(false);
368
373
  }