@adhdev/daemon-core 0.9.82-rc.217 → 0.9.82-rc.219

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.
@@ -18,11 +18,13 @@
18
18
  */
19
19
  'use strict';
20
20
 
21
- import { SpecDriver, type DashboardEvent } from './driver.js';
22
- import { evaluate } from './evaluator.js';
21
+ import { SpecDriver, type DashboardEvent, type ISpecDriver } from './driver.js';
22
+ import { FsmDriver } from './fsm-driver.js';
23
23
  import { executeNativeHistory } from './native-history-executor.js';
24
24
  import { loadSpec } from './loader.js';
25
+ import * as fs from 'node:fs';
25
26
  import type { CliSpec } from './types.js';
27
+ import type { NativeHistoryConfig, Control } from './types.js';
26
28
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
27
29
  import type { ChatMessage } from '../../types.js';
28
30
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
@@ -37,6 +39,14 @@ import {
37
39
  type InteractivePromptResponse,
38
40
  } from '../types/interactive-prompt.js';
39
41
 
42
+ /** Peek at the spec's $schema to choose the driver. Cheap header read. */
43
+ function detectV4Schema(specPath: string): boolean {
44
+ try {
45
+ const raw = JSON.parse(fs.readFileSync(specPath, 'utf8'));
46
+ return raw?.$schema === 'adhdev:cli/spec@4';
47
+ } catch { return false; }
48
+ }
49
+
40
50
  function stripAnsi(text: string): string {
41
51
  // eslint-disable-next-line no-control-regex
42
52
  return String(text || '')
@@ -59,8 +69,14 @@ export class SpecCliAdapter implements CliAdapter {
59
69
  */
60
70
  readonly chatMessagesOwnedExternally = true as const;
61
71
 
62
- private driver: SpecDriver;
63
- private spec: CliSpec;
72
+ private driver: ISpecDriver;
73
+ /** Common spec fields the adapter reads, present in both v3 and v4. */
74
+ private spec: {
75
+ id: string;
76
+ name: string;
77
+ control_bar?: Control[];
78
+ native_history?: NativeHistoryConfig;
79
+ };
64
80
  private lastEvent: DashboardEvent | null = null;
65
81
  private latestState: { id: string; label: string; title: string | null } | null = null;
66
82
  private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
@@ -97,9 +113,30 @@ export class SpecCliAdapter implements CliAdapter {
97
113
  extraEnv: Record<string, string>,
98
114
  transportFactory?: PtyTransportFactory,
99
115
  ) {
100
- const res = loadSpec(specPath);
101
- if (!res.ok) throw new Error(`spec invalid (${specPath}): ${res.errors.join('; ')}`);
102
- this.spec = res.spec;
116
+ // Detect the spec schema version up front. v4 (FSM) and v3 (debounce)
117
+ // use different drivers behind the same ISpecDriver interface.
118
+ const isV4 = detectV4Schema(specPath);
119
+
120
+ if (isV4) {
121
+ // v4 loader runs inside FsmDriver; read the common header fields
122
+ // we need here directly from the parsed JSON.
123
+ const raw = JSON.parse(fs.readFileSync(specPath, 'utf8'));
124
+ this.spec = {
125
+ id: raw.id,
126
+ name: raw.name,
127
+ control_bar: raw.control_bar,
128
+ native_history: raw.native_history,
129
+ };
130
+ } else {
131
+ const res = loadSpec(specPath);
132
+ if (!res.ok) throw new Error(`spec invalid (${specPath}): ${res.errors.join('; ')}`);
133
+ this.spec = {
134
+ id: res.spec.id,
135
+ name: res.spec.name,
136
+ control_bar: res.spec.control_bar,
137
+ native_history: res.spec.native_history,
138
+ };
139
+ }
103
140
  this.cliType = this.spec.id;
104
141
  this.cliName = this.spec.name;
105
142
  this.workingDir = workingDir;
@@ -107,11 +144,11 @@ export class SpecCliAdapter implements CliAdapter {
107
144
 
108
145
  // cli-manager.ts allocates providerSessionId per launch and threads
109
146
  // it through resume.newSessionArgs as additional cliArgs (e.g.
110
- // ["--session-id", "<uuid>"]). We must hand those to SpecDriver
147
+ // ["--session-id", "<uuid>"]). We must hand those to the driver
111
148
  // so the agent uses the daemon's id, otherwise (claude case) the
112
149
  // agent generates its own id and the chat-history pipeline can't
113
150
  // pair the on-disk transcript with the live session.
114
- this.driver = new SpecDriver({
151
+ const driverOpts = {
115
152
  specPath,
116
153
  workingDir,
117
154
  extraEnv,
@@ -119,7 +156,8 @@ export class SpecCliAdapter implements CliAdapter {
119
156
  emitTrace: false,
120
157
  transportFactory,
121
158
  extraCliArgs: cliArgs,
122
- });
159
+ };
160
+ this.driver = isV4 ? new FsmDriver(driverOpts) : new SpecDriver(driverOpts);
123
161
  this.driver.subscribe((ev) => this.handleEvent(ev));
124
162
  }
125
163
 
@@ -362,6 +400,10 @@ export class SpecCliAdapter implements CliAdapter {
362
400
  specPath: this.driver.getSpecPath(),
363
401
  cursorPosition: this.driver.getCursorPosition(),
364
402
  completionIdleDebounce: this.driver.getCompletionIdleDebounceState(),
403
+ // v4 FSM live transition table (null for v3 specs). Every outgoing
404
+ // transition from the current state with its per-condition match
405
+ // result + countdown — the canonical "why isn't it moving" answer.
406
+ fsm: this.driver.getFsmDebug?.() ?? null,
365
407
  // Extended fields
366
408
  name: this.cliName,
367
409
  status: this.getStatus().status,
@@ -445,10 +487,10 @@ export class SpecCliAdapter implements CliAdapter {
445
487
  }
446
488
  }
447
489
 
448
- private readCurrentScreenSections(screenText: string): Record<string, string> {
490
+ private readCurrentScreenSections(_screenText: string): Record<string, string> {
449
491
  try {
450
- const ev = evaluate(this.spec, screenText);
451
- return Object.fromEntries(ev.sections.map(section => [section.id, section.text]));
492
+ const sections = this.driver.getSections() ?? [];
493
+ return Object.fromEntries(sections.map(section => [section.id, section.text]));
452
494
  } catch {
453
495
  return {};
454
496
  }
@@ -601,7 +643,13 @@ export class SpecCliAdapter implements CliAdapter {
601
643
  providerSessionId: this.providerSessionId ?? null,
602
644
  sections: this.driver.getSections?.() ?? null,
603
645
  stateHistory: history,
604
- specPath: (this.driver as any).opts?.specPath ?? null,
646
+ specPath: this.driver.getSpecPath?.() ?? null,
647
+ // v4 FSM live transition table — present only for FsmDriver. Lets
648
+ // the panel (and the daemon API) show, for the current instant,
649
+ // every outgoing transition with its per-condition match result
650
+ // and countdown. This is the canonical "why isn't it transitioning"
651
+ // answer — no screenshots needed.
652
+ fsm: this.driver.getFsmDebug?.() ?? null,
605
653
  messages,
606
654
  committedMessages: messages,
607
655
  };
@@ -65,6 +65,45 @@ export type DashboardCommand =
65
65
  | { kind: 'cancel' }
66
66
  | { kind: 'shutdown' };
67
67
 
68
+ /** One state-history entry — the union of fields produced by the v3 SpecDriver
69
+ * (debounce-based) and the v4 FsmDriver (transition-based). The cli-adapter
70
+ * and debug panel read this shape from either driver. */
71
+ export interface DriverHistoryEntry {
72
+ stateId: string;
73
+ label: string;
74
+ at: number;
75
+ durationMs: number;
76
+ reason: string;
77
+ matchedStateId?: string;
78
+ matchedRules?: string[];
79
+ debounceKind?: string;
80
+ idleHoldMs?: number;
81
+ busyHoldMs?: number;
82
+ /** v4: the transition that fired, e.g. "idle→busy". */
83
+ via?: string;
84
+ }
85
+
86
+ /** The surface the cli-adapter drives. Implemented by both SpecDriver (v3,
87
+ * debounce) and FsmDriver (v4, FSM). Lets the adapter hold either without
88
+ * branching on the concrete type. */
89
+ export interface ISpecDriver {
90
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
91
+ start(): void;
92
+ dispatch(cmd: DashboardCommand): void;
93
+ snapshot(): string;
94
+ getCursorPosition(): { row: number; col: number };
95
+ getScreen(): string;
96
+ getSpecPath(): string;
97
+ shutdown(): void;
98
+ getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
99
+ getSections(): Array<{ id: string; text: string }> | null;
100
+ getLastBusyAt(): number;
101
+ hasIdleHoldPending(): boolean;
102
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
103
+ /** v4 only — present on FsmDriver. Returns the live transition table. */
104
+ getFsmDebug?(): unknown;
105
+ }
106
+
68
107
  export interface SpecDriverOpts {
69
108
  specPath: string;
70
109
  workingDir: string;
@@ -195,7 +234,7 @@ export function matchesCompletionIdleTargetState(
195
234
  }
196
235
  }
197
236
 
198
- export class SpecDriver {
237
+ export class SpecDriver implements ISpecDriver {
199
238
  private spec!: CliSpec;
200
239
  private adapter!: TerminalAdapter;
201
240
  private listeners = new Set<(ev: DashboardEvent) => void>();
@@ -535,6 +574,19 @@ export class SpecDriver {
535
574
  evState = this.lastBusyState ?? evState;
536
575
  }
537
576
  }
577
+ // Startup grace: suppress idle→busy transitions during the banner-paint
578
+ // window. The spec author sets startup_grace_ms to cover the time the
579
+ // terminal spends drawing its initial screen; any busy signal during
580
+ // that window is noise (cursor_above:changed, layout reflow, etc.).
581
+ const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
582
+ const sinceStartMs = now - this.startedAtMs;
583
+ // Startup grace: suppress all busy transitions during the banner-paint
584
+ // window regardless of current state.
585
+ if (sinceStartMs < graceMs && evState.id === 'busy') {
586
+ LOG.info('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] startup grace suppressed busy (sinceStart=${sinceStartMs}ms grace=${graceMs}ms)`);
587
+ evState = { id: this.spec.default_state ?? 'idle', label: 'Ready', title: null };
588
+ this.scheduleBusyExpiry(graceMs - sinceStartMs + 50);
589
+ }
538
590
  // stable_ms gate: if the matched idle state has a changed:false/stable_ms
539
591
  // condition, verify the region has been stable long enough. If not, pin
540
592
  // to busy and schedule a re-evaluation when the stable window expires.
@@ -783,8 +835,7 @@ export class SpecDriver {
783
835
  // reading during the banner paint as a real idle. After that,
784
836
  // the first non-busy observation is a real prompt-ready signal
785
837
  // and we drain any queued send_message calls.
786
- const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
787
- const sinceStart = Date.now() - this.startedAtMs;
838
+ const sinceStart = now - this.startedAtMs;
788
839
  if (!this.idleSeenOnce && evState.id !== 'busy' && sinceStart >= graceMs) {
789
840
  this.idleSeenOnce = true;
790
841
  const queued = this.pendingSends.splice(0);
@@ -1014,7 +1065,7 @@ function sectionTextFromSnapshot(spec: CliSpec, screen: string, sectionId: strin
1014
1065
  return ev.sections.find(s => s.id === sectionId)?.text ?? null;
1015
1066
  }
1016
1067
 
1017
- function guessExt(mime: string): string {
1068
+ export function guessExt(mime: string): string {
1018
1069
  if (/png/i.test(mime)) return '.png';
1019
1070
  if (/jpe?g/i.test(mime)) return '.jpg';
1020
1071
  if (/gif/i.test(mime)) return '.gif';
@@ -81,7 +81,7 @@ function resolveSize(size: number | string | undefined, total: number): number {
81
81
  * Resolve v3 sections{} object into an ordered array of ResolvedSection.
82
82
  * Two-pass: first anchor/positional, then apply `until` cross-references.
83
83
  */
84
- function resolveSections(
84
+ export function resolveSections(
85
85
  sectionsObj: Record<string, SectionDef>,
86
86
  lines: string[],
87
87
  ): ResolvedSection[] {
@@ -167,7 +167,7 @@ function resolveSections(
167
167
  return resolved;
168
168
  }
169
169
 
170
- function sectionText(sections: ResolvedSection[], sectionId: string | undefined, fullScreen: string): string {
170
+ export function sectionText(sections: ResolvedSection[], sectionId: string | undefined, fullScreen: string): string {
171
171
  if (!sectionId) return fullScreen;
172
172
  const found = sections.find(s => s.id === sectionId);
173
173
  return found ? found.text : '';
@@ -193,7 +193,7 @@ function isAnyCondition(c: Condition): c is AnyCondition {
193
193
  return 'any' in c;
194
194
  }
195
195
 
196
- function evaluateCondition(
196
+ export function evaluateCondition(
197
197
  cond: Condition,
198
198
  sections: ResolvedSection[],
199
199
  fullScreen: string,
@@ -231,7 +231,11 @@ function evaluateCondition(
231
231
  // changed:false means "region is currently stable" — stable_ms duration
232
232
  // is enforced by the driver, not here.
233
233
  const result = cond.changed ? didChange : !didChange;
234
- trace.push({ kind: 'section', text: `state[${stateId}] changed cond cursor_above=${cond.cursor_above} rows[${startRow},${endRow}) changed=${didChange} expected=${cond.changed} result=${result}` });
234
+ const stableSuffix = cond.stable_ms != null ? ` stable_ms=${cond.stable_ms}` : '';
235
+ trace.push({
236
+ kind: result ? 'state_match' : 'state_skip',
237
+ text: `state[${stateId}] changed cond cursor_above=${cond.cursor_above} rows[${startRow},${endRow}) changed=${didChange} expected=${cond.changed}${stableSuffix} result=${result}`,
238
+ });
235
239
  return result;
236
240
  }
237
241
 
@@ -317,7 +321,7 @@ function matchState(
317
321
  return { matched: true, title };
318
322
  }
319
323
 
320
- function extractTitle(
324
+ export function extractTitle(
321
325
  rule: ExtractTitle,
322
326
  sections: ResolvedSection[],
323
327
  fullScreen: string,
@@ -358,7 +362,7 @@ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
358
362
  return new RegExp(ref.pattern, flags);
359
363
  }
360
364
 
361
- function extractButtonsFromRule(
365
+ export function extractButtonsFromRule(
362
366
  rule: ExtractButtons,
363
367
  hay: string,
364
368
  ): { index: number; label: string; key: string }[] {