@adhdev/daemon-core 0.9.82-rc.260 → 0.9.82-rc.261

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.
@@ -26,6 +26,23 @@ export declare class SpecCliAdapter implements CliAdapter {
26
26
  private activeInteractivePrompt;
27
27
  private interactivePromptTransport;
28
28
  private claudeTuiPromptCaptureInFlight;
29
+ /**
30
+ * Wall clock of the first frame on which a held interactive prompt was
31
+ * observed to have left the screen. Mirrors the approval FSM's
32
+ * `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
33
+ * repaints the choice picker as several PTY chunks, so a single frame
34
+ * with no "Enter to select" footer is not proof the prompt is gone — it
35
+ * may just be mid-repaint. We only clear the held prompt once it has
36
+ * been absent across a short grace window. Reset to null the moment the
37
+ * prompt footer reappears.
38
+ *
39
+ * Without this, a choice prompt resolved *directly in the terminal* (the
40
+ * user picked an option without going through ADHDev's
41
+ * setInteractivePromptResponse) was never cleared from
42
+ * `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
43
+ * forever — the choice-resolve-stuck bug.
44
+ */
45
+ private interactivePromptLostAt;
29
46
  private jsonLineTail;
30
47
  private exited;
31
48
  private spawned;
@@ -93,6 +110,27 @@ export declare class SpecCliAdapter implements CliAdapter {
93
110
  private readCurrentScreenSections;
94
111
  private extractProviderSessionIdFromScreen;
95
112
  private readClaudeScreenAssistantMessages;
113
+ /**
114
+ * Grace window a held interactive prompt must be absent from the screen
115
+ * before we treat it as resolved-in-terminal and clear it. claude-cli
116
+ * repaints the picker across multiple PTY chunks, so a single
117
+ * footer-less frame is not proof the prompt is gone. Sized in the same
118
+ * spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
119
+ */
120
+ private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS;
121
+ /**
122
+ * Clear a held interactive prompt once the user has resolved it directly
123
+ * in the terminal (the choice picker leaves the screen without going
124
+ * through setInteractivePromptResponse). The approval path already does
125
+ * this via the FSM's modal-lost hysteresis; the interactive-prompt path
126
+ * had no equivalent, so a terminal-side answer left activeInteractivePrompt
127
+ * set and getStatus() re-emitted the same choice modal forever.
128
+ *
129
+ * Detection mirrors capture: the claude TUI picker is on-screen exactly
130
+ * while its "Enter to select" footer is rendered. When the footer is gone
131
+ * for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
132
+ */
133
+ private maybeClearResolvedClaudeTuiPrompt;
96
134
  private maybeCaptureClaudeTuiPrompt;
97
135
  private readClaudeTuiHeaders;
98
136
  private captureClaudeTuiPrompt;
@@ -82,6 +82,36 @@ export interface DriverHistoryEntry {
82
82
  busyHoldMs?: number;
83
83
  via?: string;
84
84
  }
85
+ /**
86
+ * A frozen snapshot of the FULL FSM evaluation captured at the instant a
87
+ * transition fired — the rich `transitions[]` table (per-transition eligible /
88
+ * hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
89
+ * otherwise only computes live for the current instant. Kept in a separate ring
90
+ * buffer from `stateHistory` (which stays intentionally lightweight) so the
91
+ * "why did this rule fire just before the transition" question is answerable
92
+ * after the fact. before-only: this is the evaluation that PRODUCED the
93
+ * transition, not the post-transition state.
94
+ */
95
+ export interface FsmSnapshotEntry {
96
+ /** State we transitioned out of. */
97
+ stateFrom: string;
98
+ /** State we transitioned into (the fired transition's destination). */
99
+ stateTo: string;
100
+ /** Wall-clock time the transition committed (ms). */
101
+ at: number;
102
+ /** The fired transition's destination state id (== stateTo; kept explicit
103
+ * to mirror the rule that fired). */
104
+ firedTo: string;
105
+ /** Human label of the fired transition (e.g. "approval→busy"). */
106
+ firedLabel: string;
107
+ /** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
108
+ reason: string[];
109
+ /** Every outgoing transition from `stateFrom` as evaluated at `at`, each
110
+ * with its eligible / hold / per-condition CondResult + remainingMs. This
111
+ * is the full pre-transition evaluation table — the whole point of the
112
+ * snapshot. */
113
+ transitions: TransitionEval[];
114
+ }
85
115
  export interface ISpecDriver {
86
116
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
87
117
  start(): void;
@@ -108,6 +138,7 @@ export interface ISpecDriver {
108
138
  forceAfterMs: number;
109
139
  } | null;
110
140
  getFsmDebug?(): unknown;
141
+ getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
111
142
  }
112
143
  export interface SpecDriverOpts {
113
144
  specPath: string;
@@ -148,6 +179,10 @@ export declare class FsmDriver implements ISpecDriver {
148
179
  private specWatcher;
149
180
  /** Last full FSM evaluation, kept for the debugger. */
150
181
  private lastFsmEval;
182
+ /** Ring buffer (max 20) of the full FSM evaluation captured at each
183
+ * transition — the rich pre-transition table that lastFsmEval only keeps
184
+ * for the single most recent evaluation. Separate from stateHistory. */
185
+ private fsmSnapshotHistory;
151
186
  constructor(opts: SpecDriverOpts);
152
187
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
153
188
  start(): void;
@@ -172,6 +207,7 @@ export declare class FsmDriver implements ISpecDriver {
172
207
  transitions: TransitionEval[];
173
208
  };
174
209
  getStateHistory(): ReadonlyArray<HistoryEntry>;
210
+ getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry>;
175
211
  getSections(): Array<{
176
212
  id: string;
177
213
  text: string;
@@ -194,6 +230,11 @@ export declare class FsmDriver implements ISpecDriver {
194
230
  private evalFsmNow;
195
231
  private reevaluate;
196
232
  private commitTransition;
233
+ /** Snapshot the full FSM evaluation that produced a transition into the
234
+ * separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
235
+ * table is captured by reference — it is freshly built per evaluation in
236
+ * evaluateFsm and never mutated after, so no clone is needed. */
237
+ private pushFsmSnapshot;
197
238
  /** Re-derive the visible modal + controls for the current state and emit a
198
239
  * state_changed if anything differs from the last emit. */
199
240
  private emitStateChanged;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.260",
3
+ "version": "0.9.82-rc.261",
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",
@@ -62,9 +62,16 @@ export class TerminalAdapter {
62
62
  this.screenDebounceMs = opts.screenChangeDebounceMs ?? 80;
63
63
  this.tickIntervalMs = opts.tickIntervalMs ?? 0;
64
64
  // Import NodePtyTransportFactory lazily to avoid loading node-pty in
65
- // environments that don't need it (tests, fixture runners).
66
- const { NodePtyTransportFactory } = require('../../cli-adapters/pty-transport.js');
67
- this.factory = opts.transportFactory ?? new NodePtyTransportFactory();
65
+ // environments that don't need it (tests, fixture runners) — only when
66
+ // no transport factory was injected. Doing it unconditionally pulled in
67
+ // the node-pty module (and broke source-level test runs) even when a
68
+ // fake factory was supplied.
69
+ if (opts.transportFactory) {
70
+ this.factory = opts.transportFactory;
71
+ } else {
72
+ const { NodePtyTransportFactory } = require('../../cli-adapters/pty-transport.js');
73
+ this.factory = new NodePtyTransportFactory();
74
+ }
68
75
  this.screen = new TerminalScreen(this.rows, this.cols);
69
76
  }
70
77
 
@@ -76,6 +76,23 @@ export class SpecCliAdapter implements CliAdapter {
76
76
  private activeInteractivePrompt: InteractivePrompt | null = null;
77
77
  private interactivePromptTransport: 'stream-json' | 'tui' | null = null;
78
78
  private claudeTuiPromptCaptureInFlight = false;
79
+ /**
80
+ * Wall clock of the first frame on which a held interactive prompt was
81
+ * observed to have left the screen. Mirrors the approval FSM's
82
+ * `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
83
+ * repaints the choice picker as several PTY chunks, so a single frame
84
+ * with no "Enter to select" footer is not proof the prompt is gone — it
85
+ * may just be mid-repaint. We only clear the held prompt once it has
86
+ * been absent across a short grace window. Reset to null the moment the
87
+ * prompt footer reappears.
88
+ *
89
+ * Without this, a choice prompt resolved *directly in the terminal* (the
90
+ * user picked an option without going through ADHDev's
91
+ * setInteractivePromptResponse) was never cleared from
92
+ * `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
93
+ * forever — the choice-resolve-stuck bug.
94
+ */
95
+ private interactivePromptLostAt: number | null = null;
79
96
  private jsonLineTail = '';
80
97
  private exited = false;
81
98
  private spawned = false;
@@ -380,6 +397,11 @@ export class SpecCliAdapter implements CliAdapter {
380
397
  // transition from the current state with its per-condition match
381
398
  // result + countdown — the canonical "why isn't it moving" answer.
382
399
  fsm: this.driver.getFsmDebug?.() ?? null,
400
+ // v4 FSM transition snapshot history (null for v3 specs). The full
401
+ // pre-transition evaluation table captured at each transition —
402
+ // answers "why did this rule fire" after the fact, unlike the live
403
+ // `fsm` field which only reflects the current instant.
404
+ fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
383
405
  // Extended fields
384
406
  name: this.cliName,
385
407
  status: this.getStatus().status,
@@ -421,11 +443,13 @@ export class SpecCliAdapter implements CliAdapter {
421
443
  if (ev.state.title) {
422
444
  LOG.debug('SpecAdapter', `[${this.cliType}] state.title=${JSON.stringify(ev.state.title)}`);
423
445
  }
446
+ this.maybeClearResolvedClaudeTuiPrompt();
424
447
  this.maybeCaptureClaudeTuiPrompt();
425
448
  this.statusCallback?.();
426
449
  return;
427
450
  case 'pty_data':
428
451
  this.detectInteractivePromptFromPtyChunk(ev.chunk);
452
+ this.maybeClearResolvedClaudeTuiPrompt();
429
453
  this.maybeCaptureClaudeTuiPrompt();
430
454
  try { this.ptyDataCallback?.(ev.chunk); } catch { /* ignore */ }
431
455
  return;
@@ -456,6 +480,7 @@ export class SpecCliAdapter implements CliAdapter {
456
480
  if (!prompt) continue;
457
481
  this.activeInteractivePrompt = prompt;
458
482
  this.interactivePromptTransport = 'stream-json';
483
+ this.interactivePromptLostAt = null;
459
484
  this.statusCallback?.();
460
485
  } catch {
461
486
  // PTY output is not guaranteed to be machine JSON.
@@ -514,6 +539,56 @@ export class SpecCliAdapter implements CliAdapter {
514
539
  return messages;
515
540
  }
516
541
 
542
+ /**
543
+ * Grace window a held interactive prompt must be absent from the screen
544
+ * before we treat it as resolved-in-terminal and clear it. claude-cli
545
+ * repaints the picker across multiple PTY chunks, so a single
546
+ * footer-less frame is not proof the prompt is gone. Sized in the same
547
+ * spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
548
+ */
549
+ private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS = 1500;
550
+
551
+ /**
552
+ * Clear a held interactive prompt once the user has resolved it directly
553
+ * in the terminal (the choice picker leaves the screen without going
554
+ * through setInteractivePromptResponse). The approval path already does
555
+ * this via the FSM's modal-lost hysteresis; the interactive-prompt path
556
+ * had no equivalent, so a terminal-side answer left activeInteractivePrompt
557
+ * set and getStatus() re-emitted the same choice modal forever.
558
+ *
559
+ * Detection mirrors capture: the claude TUI picker is on-screen exactly
560
+ * while its "Enter to select" footer is rendered. When the footer is gone
561
+ * for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
562
+ */
563
+ private maybeClearResolvedClaudeTuiPrompt(): void {
564
+ if (this.cliType !== 'claude-cli' || !this.activeInteractivePrompt) return;
565
+ // stream-json prompts are tracked by their tool-call lifecycle, not by
566
+ // screen footer, but claude renders the same TUI picker for both
567
+ // transports while awaiting an answer — so screen presence is a valid
568
+ // resolved-signal for either. (If the screen read fails, keep holding.)
569
+ let screenText = '';
570
+ try {
571
+ screenText = this.driver.snapshot();
572
+ } catch {
573
+ return;
574
+ }
575
+ const stillOnScreen = screenText.includes('Enter to select');
576
+ if (stillOnScreen) {
577
+ // Prompt reappeared / never left — reset the hysteresis timer.
578
+ this.interactivePromptLostAt = null;
579
+ return;
580
+ }
581
+ const lostAt = this.interactivePromptLostAt ?? Date.now();
582
+ if (this.interactivePromptLostAt === null) this.interactivePromptLostAt = lostAt;
583
+ if (Date.now() - lostAt < SpecCliAdapter.INTERACTIVE_PROMPT_LOST_GRACE_MS) return;
584
+ // Resolved in the terminal — drop the held prompt so getStatus() stops
585
+ // re-emitting it.
586
+ this.activeInteractivePrompt = null;
587
+ this.interactivePromptTransport = null;
588
+ this.interactivePromptLostAt = null;
589
+ this.statusCallback?.();
590
+ }
591
+
517
592
  private maybeCaptureClaudeTuiPrompt(): void {
518
593
  if (this.cliType !== 'claude-cli'
519
594
  || this.activeInteractivePrompt
@@ -529,6 +604,7 @@ export class SpecCliAdapter implements CliAdapter {
529
604
  if (!prompt) return;
530
605
  this.activeInteractivePrompt = prompt;
531
606
  this.interactivePromptTransport = 'tui';
607
+ this.interactivePromptLostAt = null;
532
608
  this.statusCallback?.();
533
609
  return;
534
610
  }
@@ -568,6 +644,7 @@ export class SpecCliAdapter implements CliAdapter {
568
644
  if (!prompt) return;
569
645
  this.activeInteractivePrompt = prompt;
570
646
  this.interactivePromptTransport = 'tui';
647
+ this.interactivePromptLostAt = null;
571
648
  this.statusCallback?.();
572
649
  }
573
650
 
@@ -626,6 +703,9 @@ export class SpecCliAdapter implements CliAdapter {
626
703
  // and countdown. This is the canonical "why isn't it transitioning"
627
704
  // answer — no screenshots needed.
628
705
  fsm: this.driver.getFsmDebug?.() ?? null,
706
+ // v4 FSM transition snapshot history — the captured pre-transition
707
+ // evaluation table at each transition (null for v3 specs).
708
+ fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
629
709
  messages,
630
710
  committedMessages: messages,
631
711
  };
@@ -28,7 +28,7 @@ import {
28
28
  resolveSections, sectionText, extractTitle, extractButtonsFromRule,
29
29
  type ResolvedSection, type TraceEntry,
30
30
  } from './evaluator.js';
31
- import { evaluateFsm, type FsmClock, type TransitionEval } from './fsm-evaluator.js';
31
+ import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
32
32
  import {
33
33
  type CliSpecV4, type FsmState, type FsmTransition,
34
34
  initialState, stateById, statusForState, outgoingTransitions,
@@ -74,6 +74,37 @@ export interface DriverHistoryEntry {
74
74
  via?: string;
75
75
  }
76
76
 
77
+ /**
78
+ * A frozen snapshot of the FULL FSM evaluation captured at the instant a
79
+ * transition fired — the rich `transitions[]` table (per-transition eligible /
80
+ * hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
81
+ * otherwise only computes live for the current instant. Kept in a separate ring
82
+ * buffer from `stateHistory` (which stays intentionally lightweight) so the
83
+ * "why did this rule fire just before the transition" question is answerable
84
+ * after the fact. before-only: this is the evaluation that PRODUCED the
85
+ * transition, not the post-transition state.
86
+ */
87
+ export interface FsmSnapshotEntry {
88
+ /** State we transitioned out of. */
89
+ stateFrom: string;
90
+ /** State we transitioned into (the fired transition's destination). */
91
+ stateTo: string;
92
+ /** Wall-clock time the transition committed (ms). */
93
+ at: number;
94
+ /** The fired transition's destination state id (== stateTo; kept explicit
95
+ * to mirror the rule that fired). */
96
+ firedTo: string;
97
+ /** Human label of the fired transition (e.g. "approval→busy"). */
98
+ firedLabel: string;
99
+ /** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
100
+ reason: string[];
101
+ /** Every outgoing transition from `stateFrom` as evaluated at `at`, each
102
+ * with its eligible / hold / per-condition CondResult + remainingMs. This
103
+ * is the full pre-transition evaluation table — the whole point of the
104
+ * snapshot. */
105
+ transitions: TransitionEval[];
106
+ }
107
+
77
108
  export interface ISpecDriver {
78
109
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
79
110
  start(): void;
@@ -89,6 +120,7 @@ export interface ISpecDriver {
89
120
  hasIdleHoldPending(): boolean;
90
121
  getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
91
122
  getFsmDebug?(): unknown;
123
+ getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
92
124
  }
93
125
 
94
126
  export interface SpecDriverOpts {
@@ -182,6 +214,10 @@ export class FsmDriver implements ISpecDriver {
182
214
  private specWatcher: fs.FSWatcher | null = null;
183
215
  /** Last full FSM evaluation, kept for the debugger. */
184
216
  private lastFsmEval: ReturnType<typeof evaluateFsm> | null = null;
217
+ /** Ring buffer (max 20) of the full FSM evaluation captured at each
218
+ * transition — the rich pre-transition table that lastFsmEval only keeps
219
+ * for the single most recent evaluation. Separate from stateHistory. */
220
+ private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
185
221
 
186
222
  constructor(private readonly opts: SpecDriverOpts) {
187
223
  this.loadSpecOrThrow();
@@ -272,6 +308,7 @@ export class FsmDriver implements ISpecDriver {
272
308
  }
273
309
 
274
310
  getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
311
+ getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
275
312
  getSections(): Array<{ id: string; text: string }> | null {
276
313
  try {
277
314
  const screen = this.adapter.snapshot();
@@ -385,7 +422,7 @@ export class FsmDriver implements ISpecDriver {
385
422
  this.prevScreenLines = currentLines;
386
423
 
387
424
  if (ev.fired) {
388
- this.commitTransition(ev.fired, now);
425
+ this.commitTransition(ev.fired, now, ev);
389
426
  // After a transition, immediately re-derive controls/modal for the
390
427
  // new state and emit. Re-run once so a chain like approval→busy
391
428
  // that's already satisfied doesn't wait for the next PTY frame.
@@ -403,8 +440,11 @@ export class FsmDriver implements ISpecDriver {
403
440
  this.maybeMarkReady();
404
441
  }
405
442
 
406
- private commitTransition(fired: TransitionEval, now: number): void {
443
+ private commitTransition(fired: TransitionEval, now: number, ev: FsmEvaluation): void {
407
444
  const from = this.currentStateId;
445
+ // Capture the full pre-transition evaluation BEFORE we mutate state, so
446
+ // the snapshot records why this transition fired from `from`.
447
+ this.pushFsmSnapshot(from, fired, now, ev);
408
448
  this.currentStateId = fired.to;
409
449
  this.stateEnteredAt = now;
410
450
  // Region change timestamps are relative to the previous state's
@@ -418,6 +458,23 @@ export class FsmDriver implements ISpecDriver {
418
458
  LOG.info('FsmDriver', `[${this.specTag()}] ${from} → ${fired.to} (${fired.label})`);
419
459
  }
420
460
 
461
+ /** Snapshot the full FSM evaluation that produced a transition into the
462
+ * separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
463
+ * table is captured by reference — it is freshly built per evaluation in
464
+ * evaluateFsm and never mutated after, so no clone is needed. */
465
+ private pushFsmSnapshot(from: string, fired: TransitionEval, now: number, ev: FsmEvaluation): void {
466
+ this.fsmSnapshotHistory.push({
467
+ stateFrom: from,
468
+ stateTo: fired.to,
469
+ at: now,
470
+ firedTo: fired.to,
471
+ firedLabel: fired.label,
472
+ reason: summarizeTransition(fired),
473
+ transitions: ev.transitions,
474
+ });
475
+ if (this.fsmSnapshotHistory.length > 20) this.fsmSnapshotHistory.shift();
476
+ }
477
+
421
478
  /** Re-derive the visible modal + controls for the current state and emit a
422
479
  * state_changed if anything differs from the last emit. */
423
480
  private emitStateChanged(forceEmit: boolean): void {