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

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.
@@ -15,6 +15,7 @@ export declare class SpecCliAdapter implements CliAdapter {
15
15
  */
16
16
  readonly chatMessagesOwnedExternally: true;
17
17
  private driver;
18
+ /** Common spec fields the adapter reads, present in both v3 and v4. */
18
19
  private spec;
19
20
  private lastEvent;
20
21
  private latestState;
@@ -68,6 +68,54 @@ export type DashboardCommand = {
68
68
  } | {
69
69
  kind: 'shutdown';
70
70
  };
71
+ /** One state-history entry — the union of fields produced by the v3 SpecDriver
72
+ * (debounce-based) and the v4 FsmDriver (transition-based). The cli-adapter
73
+ * and debug panel read this shape from either driver. */
74
+ export interface DriverHistoryEntry {
75
+ stateId: string;
76
+ label: string;
77
+ at: number;
78
+ durationMs: number;
79
+ reason: string;
80
+ matchedStateId?: string;
81
+ matchedRules?: string[];
82
+ debounceKind?: string;
83
+ idleHoldMs?: number;
84
+ busyHoldMs?: number;
85
+ /** v4: the transition that fired, e.g. "idle→busy". */
86
+ via?: string;
87
+ }
88
+ /** The surface the cli-adapter drives. Implemented by both SpecDriver (v3,
89
+ * debounce) and FsmDriver (v4, FSM). Lets the adapter hold either without
90
+ * branching on the concrete type. */
91
+ export interface ISpecDriver {
92
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
93
+ start(): void;
94
+ dispatch(cmd: DashboardCommand): void;
95
+ snapshot(): string;
96
+ getCursorPosition(): {
97
+ row: number;
98
+ col: number;
99
+ };
100
+ getScreen(): string;
101
+ getSpecPath(): string;
102
+ shutdown(): void;
103
+ getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
104
+ getSections(): Array<{
105
+ id: string;
106
+ text: string;
107
+ }> | null;
108
+ getLastBusyAt(): number;
109
+ hasIdleHoldPending(): boolean;
110
+ getCompletionIdleDebounceState(): {
111
+ active: boolean;
112
+ ageMs: number;
113
+ holdMs: number;
114
+ forceAfterMs: number;
115
+ } | null;
116
+ /** v4 only — present on FsmDriver. Returns the live transition table. */
117
+ getFsmDebug?(): unknown;
118
+ }
71
119
  export interface SpecDriverOpts {
72
120
  specPath: string;
73
121
  workingDir: string;
@@ -102,7 +150,7 @@ export declare function matchesCompletionIdleTargetState(spec: CliSpec, ev: Spec
102
150
  row: number;
103
151
  col: number;
104
152
  }): boolean;
105
- export declare class SpecDriver {
153
+ export declare class SpecDriver implements ISpecDriver {
106
154
  private readonly opts;
107
155
  private spec;
108
156
  private adapter;
@@ -236,3 +284,4 @@ export declare class SpecDriver {
236
284
  private handleExit;
237
285
  private emit;
238
286
  }
287
+ export declare function guessExt(mime: string): string;
@@ -1,4 +1,4 @@
1
- import type { CliSpec } from './types.js';
1
+ import type { CliSpec, SectionDef, Condition, ExtractTitle, ExtractButtons } from './types.js';
2
2
  export interface ResolvedSection {
3
3
  id: string;
4
4
  fromLine: number;
@@ -44,6 +44,22 @@ export interface SpecEvaluation {
44
44
  sections: ResolvedSection[];
45
45
  trace: TraceEntry[];
46
46
  }
47
+ /**
48
+ * Resolve v3 sections{} object into an ordered array of ResolvedSection.
49
+ * Two-pass: first anchor/positional, then apply `until` cross-references.
50
+ */
51
+ export declare function resolveSections(sectionsObj: Record<string, SectionDef>, lines: string[]): ResolvedSection[];
52
+ export declare function sectionText(sections: ResolvedSection[], sectionId: string | undefined, fullScreen: string): string;
53
+ export declare function evaluateCondition(cond: Condition, sections: ResolvedSection[], fullScreen: string, cursor: {
54
+ row: number;
55
+ col: number;
56
+ } | undefined, prevLines: string[] | undefined, trace: TraceEntry[], stateId: string): boolean;
57
+ export declare function extractTitle(rule: ExtractTitle, sections: ResolvedSection[], fullScreen: string): string | null;
58
+ export declare function extractButtonsFromRule(rule: ExtractButtons, hay: string): {
59
+ index: number;
60
+ label: string;
61
+ key: string;
62
+ }[];
47
63
  export declare function evaluate(spec: CliSpec, screenText: string,
48
64
  /** Optional cursor position (0-based row and col). */
49
65
  cursor?: {
@@ -0,0 +1,110 @@
1
+ import { type TransitionEval } from './fsm-evaluator.js';
2
+ import { type DashboardEvent, type DashboardCommand, type SpecDriverOpts, type ISpecDriver, type DriverHistoryEntry } from './driver.js';
3
+ type HistoryEntry = DriverHistoryEntry;
4
+ export declare class FsmDriver implements ISpecDriver {
5
+ private readonly opts;
6
+ private spec;
7
+ private adapter;
8
+ private listeners;
9
+ private currentStateId;
10
+ private stateEnteredAt;
11
+ private startedAtMs;
12
+ private prevScreenLines;
13
+ /** Per cursor_above region (key: cursor_above, -1 = whole screen) → last
14
+ * time that region's content changed. Drives stable_ms conditions. */
15
+ private regionLastChangedAt;
16
+ /** Timer that re-runs evaluate() when a time-condition would flip true
17
+ * with no PTY frame to trigger it. */
18
+ private wakeTimer;
19
+ private currentEval;
20
+ private stateHistory;
21
+ private prevStateAt;
22
+ private readySeenOnce;
23
+ private pendingSends;
24
+ private pickerInProgress;
25
+ private delegateTimers;
26
+ private specWatcher;
27
+ /** Last full FSM evaluation, kept for the debugger. */
28
+ private lastFsmEval;
29
+ constructor(opts: SpecDriverOpts);
30
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
31
+ start(): void;
32
+ dispatch(cmd: DashboardCommand): void;
33
+ snapshot(): string;
34
+ getCursorPosition(): {
35
+ row: number;
36
+ col: number;
37
+ };
38
+ getScreen(): string;
39
+ getSpecPath(): string;
40
+ shutdown(): void;
41
+ getFsmDebug(): {
42
+ currentState: string;
43
+ label: string;
44
+ stateAgeMs: number;
45
+ status: string;
46
+ cursor: {
47
+ row: number;
48
+ col: number;
49
+ };
50
+ transitions: TransitionEval[];
51
+ };
52
+ getStateHistory(): ReadonlyArray<HistoryEntry>;
53
+ getSections(): Array<{
54
+ id: string;
55
+ text: string;
56
+ }> | null;
57
+ /** v3-compat shims so the cli-adapter's existing debug snapshot keeps
58
+ * working without branching on driver type. */
59
+ getLastBusyAt(): number;
60
+ hasIdleHoldPending(): boolean;
61
+ getCompletionIdleDebounceState(): {
62
+ active: boolean;
63
+ ageMs: number;
64
+ holdMs: number;
65
+ forceAfterMs: number;
66
+ } | null;
67
+ private loadSpecOrThrow;
68
+ private buildAdapterOpts;
69
+ private armSpecWatcher;
70
+ private emitInitialState;
71
+ private buildClock;
72
+ private evalFsmNow;
73
+ private reevaluate;
74
+ private commitTransition;
75
+ /** Re-derive the visible modal + controls for the current state and emit a
76
+ * state_changed if anything differs from the last emit. */
77
+ private emitStateChanged;
78
+ private deriveModal;
79
+ private deriveTitle;
80
+ private deriveControls;
81
+ /** Track which cursor_above regions changed since the previous frame so
82
+ * stable_ms conditions can measure quiet time. We record every region
83
+ * size referenced by a stable_ms condition in the spec, plus the whole
84
+ * screen (-1). */
85
+ private trackRegionChanges;
86
+ /** All cursor_above region sizes referenced by stable_ms conditions in the
87
+ * current state's outgoing transitions, plus whole-screen. Cached lazily
88
+ * per spec load would be nicer but the set is tiny. */
89
+ private stableRegionSizes;
90
+ /** Schedule a re-evaluation for the soonest pending time-condition on any
91
+ * outgoing transition (elapsed_ms / stable_ms / min_hold_ms). Without
92
+ * this, a state whose only exit is time-based would never leave once the
93
+ * PTY goes quiet. */
94
+ private scheduleWakeForState;
95
+ private maybeMarkReady;
96
+ private fireNotifications;
97
+ private armOrCancelDelegateTimers;
98
+ private fireDelegate;
99
+ private handleSendMessage;
100
+ private actuallySendMessage;
101
+ private handleClickControl;
102
+ private handleClickModalButton;
103
+ private handleAttachImage;
104
+ private tryAdvancePicker;
105
+ private handleExit;
106
+ private pushHistory;
107
+ private specTag;
108
+ private emit;
109
+ }
110
+ export {};
@@ -0,0 +1,72 @@
1
+ import type { SectionDef } from './types.js';
2
+ import { type ResolvedSection, type TraceEntry } from './evaluator.js';
3
+ import { type CliSpecV4, type FsmCondition } from './fsm-types.js';
4
+ export interface FsmClock {
5
+ /** Wall-clock now (ms). Passed in so the evaluator stays pure. */
6
+ now: number;
7
+ /** When the current state was entered (ms). */
8
+ stateEnteredAt: number;
9
+ /** Last-changed timestamp per cursor_above region. Key = cursor_above
10
+ * value (0 / undefined → whole-screen, keyed as -1). Missing key means
11
+ * "never observed changing" → treated as stable since stateEnteredAt. */
12
+ regionLastChangedAt: Map<number, number>;
13
+ }
14
+ /** Per-condition evaluation detail — the debugging payload. */
15
+ export interface CondResult {
16
+ kind: 'regex' | 'changed' | 'elapsed' | 'stable' | 'all' | 'any' | 'not';
17
+ result: boolean;
18
+ detail: string;
19
+ /** Remaining ms until a time-based condition would flip to true. 0 if
20
+ * already true or not applicable. Lets the UI show a countdown. */
21
+ remainingMs?: number;
22
+ children?: CondResult[];
23
+ }
24
+ /** One evaluated transition with its full reasoning. */
25
+ export interface TransitionEval {
26
+ to: string;
27
+ label: string;
28
+ /** Did the `from` clause include the current state? (Always true for the
29
+ * transitions we return — kept for completeness.) */
30
+ eligible: boolean;
31
+ /** min_hold_ms guard satisfied? */
32
+ holdSatisfied: boolean;
33
+ holdRemainingMs: number;
34
+ /** Guard condition result (true if no `when`). */
35
+ condResult: boolean;
36
+ cond?: CondResult;
37
+ /** Overall: would this transition fire? */
38
+ fires: boolean;
39
+ priority: number;
40
+ }
41
+ export interface FsmEvaluation {
42
+ /** Sections resolved from the screen (shared with v3 shape). */
43
+ sections: ResolvedSection[];
44
+ /** Every outgoing transition from the current state, priority-ordered,
45
+ * each annotated with why it fired or didn't. */
46
+ transitions: TransitionEval[];
47
+ /** The transition that fires (first in priority order with fires===true),
48
+ * or null to stay in the current state. */
49
+ fired: TransitionEval | null;
50
+ /** v3-compatible trace lines for the legacy inspector. */
51
+ trace: TraceEntry[];
52
+ }
53
+ /**
54
+ * Evaluate the FSM: given the current state id and screen, return every
55
+ * outgoing transition annotated with why it fires/doesn't, plus the one that
56
+ * fires (if any).
57
+ */
58
+ export declare function evaluateFsm(spec: CliSpecV4, currentStateId: string, screenText: string, cursor: {
59
+ row: number;
60
+ col: number;
61
+ } | undefined, prevLines: string[] | undefined, clock: FsmClock): FsmEvaluation;
62
+ /**
63
+ * Evaluate a single condition against a screen + section map — for the spec
64
+ * editor's live preview ("does this regex match the current screen right
65
+ * now?"). Time leaves (elapsed_ms/stable_ms) are evaluated against a synthetic
66
+ * clock where the state was just entered, so they report their countdown
67
+ * rather than firing; the preview is about screen-content match, not timing.
68
+ */
69
+ export declare function evaluateConditionPreview(cond: FsmCondition, sections: Record<string, SectionDef> | undefined, screenText: string, cursor?: {
70
+ row: number;
71
+ col: number;
72
+ }): CondResult;
@@ -0,0 +1,14 @@
1
+ import { type CliSpecV4 } from './fsm-types.js';
2
+ export interface FsmLoadOk {
3
+ ok: true;
4
+ spec: CliSpecV4;
5
+ sourcePath: string;
6
+ }
7
+ export interface FsmLoadErr {
8
+ ok: false;
9
+ errors: string[];
10
+ sourcePath: string;
11
+ }
12
+ export declare function loadFsmSpec(sourcePath: string): FsmLoadOk | FsmLoadErr;
13
+ /** Pure validator — usable from a "validate before save" API in the panel. */
14
+ export declare function validateFsmSpec(raw: unknown): string[];
@@ -0,0 +1,91 @@
1
+ import type { RegexCondition, ChangedCondition, Control, NotificationRule, DelegateTrigger, NativeHistoryConfig, SectionDef, ExtractTitle, ExtractButtons } from './types.js';
2
+ /** True once `ms` have elapsed since the current state was entered. */
3
+ export interface ElapsedCondition {
4
+ elapsed_ms: number;
5
+ }
6
+ /** True once the region `cursor_above` lines above the cursor has been
7
+ * unchanged for `ms`. A stability gate — the inverse of a busy signal. */
8
+ export interface StableCondition {
9
+ stable_ms: number;
10
+ /** Lines above the cursor that must be stable. Default: whole screen. */
11
+ cursor_above?: number;
12
+ }
13
+ export interface FsmAllCondition {
14
+ all: FsmCondition[];
15
+ }
16
+ export interface FsmAnyCondition {
17
+ any: FsmCondition[];
18
+ }
19
+ /** Negation — true when the inner condition is false. Lets a transition say
20
+ * "go busy unless the completion marker is present", etc. */
21
+ export interface FsmNotCondition {
22
+ not: FsmCondition;
23
+ }
24
+ export type FsmCondition = RegexCondition | ChangedCondition | ElapsedCondition | StableCondition | FsmAllCondition | FsmAnyCondition | FsmNotCondition;
25
+ export interface FsmState {
26
+ id: string;
27
+ label: string;
28
+ /** Exactly one state must be `initial: true` — the state at spawn. */
29
+ initial?: boolean;
30
+ /** Modal states (approval/picker) expose modal buttons in the UI and are
31
+ * treated as "interesting" — the dashboard surfaces them distinctly. */
32
+ modal?: boolean;
33
+ /** Status this state maps to for the dashboard/cli-adapter status field.
34
+ * One of: idle | generating | approval. Defaults: modal→approval,
35
+ * initial→idle, id==='busy'→generating, else idle. Explicit wins. */
36
+ status?: 'idle' | 'generating' | 'approval';
37
+ /** Optional extraction run whenever this state is the committed state —
38
+ * used by modal states to surface a title + buttons. */
39
+ extract?: {
40
+ title?: ExtractTitle;
41
+ buttons?: ExtractButtons;
42
+ };
43
+ }
44
+ export interface FsmTransition {
45
+ /** Source state id, or list of source ids, or "*" for any state. */
46
+ from: string | string[];
47
+ /** Destination state id. */
48
+ to: string;
49
+ /** Guard condition. Omitted → always eligible (only gated by `from` +
50
+ * min_hold_ms). Evaluated against the current screen + clock. */
51
+ when?: FsmCondition;
52
+ /** Minimum time the machine must have been in `from` before this edge can
53
+ * fire. Replaces busy_hold_ms / idle_hold_ms (per-edge, not global). */
54
+ min_hold_ms?: number;
55
+ /** Higher priority transitions are evaluated first. Default 0. Ties broken
56
+ * by declaration order. */
57
+ priority?: number;
58
+ /** Human label for the debugger. */
59
+ label?: string;
60
+ }
61
+ export interface CliSpecV4 {
62
+ $schema: 'adhdev:cli/spec@4';
63
+ id: string;
64
+ name: string;
65
+ binary: string;
66
+ spawn_args?: string[];
67
+ env?: Record<string, string>;
68
+ cli_version_range?: string;
69
+ send_message: {
70
+ submit_key: string;
71
+ delay_ms_before_submit?: number;
72
+ delay_ms_per_char?: number;
73
+ };
74
+ sections: Record<string, SectionDef>;
75
+ states: FsmState[];
76
+ transitions: FsmTransition[];
77
+ control_bar?: Control[];
78
+ notifications?: NotificationRule[];
79
+ delegate?: DelegateTrigger[];
80
+ native_history?: NativeHistoryConfig;
81
+ requiresFinalAssistantBeforeIdle?: boolean;
82
+ }
83
+ export declare function isV4Spec(raw: unknown): raw is CliSpecV4;
84
+ export declare function initialState(spec: CliSpecV4): FsmState;
85
+ export declare function stateById(spec: CliSpecV4, id: string): FsmState | undefined;
86
+ /** Outgoing transitions from `stateId`, highest priority first, declaration
87
+ * order as tiebreak. Includes wildcard ("*") and list-membership sources. */
88
+ export declare function outgoingTransitions(spec: CliSpecV4, stateId: string): FsmTransition[];
89
+ /** Map a state to the dashboard status string, applying the documented
90
+ * defaults when `status` is not explicit. */
91
+ export declare function statusForState(state: FsmState): 'idle' | 'generating' | 'approval';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.217",
3
+ "version": "0.9.82-rc.218",
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",
@@ -2322,6 +2322,48 @@ function normalizeCommandArgsWithInteractionId(args: any): Record<string, unknow
2322
2322
  return base;
2323
2323
  }
2324
2324
 
2325
+ /**
2326
+ * Confine a spec path to ~/.adhdev/providers, defeating both prefix-bypass
2327
+ * (e.g. ".../providers-evil") and symlink escape. Resolves the real path of
2328
+ * the *parent* directory (the file may not exist yet for writes), requires the
2329
+ * basename to be a literal `*.json`, and re-joins under the verified parent so
2330
+ * the returned path can't point outside the tree. Used by get/write_spec_source.
2331
+ */
2332
+ function resolveSpecPathInProviders(
2333
+ specPath: string,
2334
+ fsm: typeof import('node:fs'),
2335
+ pathm: typeof import('node:path'),
2336
+ osm: typeof import('node:os'),
2337
+ ): { ok: true; path: string } | { ok: false; error: string } {
2338
+ let rootReal: string;
2339
+ try {
2340
+ rootReal = fsm.realpathSync(pathm.join(osm.homedir(), '.adhdev', 'providers'));
2341
+ } catch (e) {
2342
+ return { ok: false, error: `providers root unavailable: ${(e as Error).message}` };
2343
+ }
2344
+ const resolved = pathm.resolve(specPath);
2345
+ const base = pathm.basename(resolved);
2346
+ if (!/^[\w.-]+\.json$/.test(base)) {
2347
+ return { ok: false, error: 'refused: spec file must be a *.json basename' };
2348
+ }
2349
+ let parentReal: string;
2350
+ try {
2351
+ parentReal = fsm.realpathSync(pathm.dirname(resolved));
2352
+ } catch (e) {
2353
+ return { ok: false, error: `spec directory not found: ${(e as Error).message}` };
2354
+ }
2355
+ if (parentReal !== rootReal && !parentReal.startsWith(rootReal + pathm.sep)) {
2356
+ return { ok: false, error: 'refused: spec path must be under the providers root' };
2357
+ }
2358
+ const safe = pathm.join(parentReal, base);
2359
+ // Reject if the final file itself is a symlink pointing elsewhere.
2360
+ try {
2361
+ const st = fsm.lstatSync(safe);
2362
+ if (st.isSymbolicLink()) return { ok: false, error: 'refused: spec path is a symlink' };
2363
+ } catch { /* file may not exist yet (write case) — fine */ }
2364
+ return { ok: true, path: safe };
2365
+ }
2366
+
2325
2367
  function toHostedCliRuntimeDescriptor(record: any): HostedCliRuntimeDescriptor | null {
2326
2368
  if (!record || typeof record !== 'object') return null;
2327
2369
  const runtimeId = typeof record.sessionId === 'string' ? record.sessionId : '';
@@ -4641,6 +4683,156 @@ export class DaemonCommandRouter {
4641
4683
  };
4642
4684
  }
4643
4685
 
4686
+ // ── Spec source read/write for the debug panel's live editor.
4687
+ // Lets the dashboard load a session's spec.json, edit it, and
4688
+ // save it back — the driver's fs.watch picks up the change and
4689
+ // hot-reloads the FSM with no restart. Writes are confined to
4690
+ // files under ~/.adhdev/providers to avoid arbitrary fs access.
4691
+ case 'get_spec_source': {
4692
+ const fsm = await import('node:fs');
4693
+ const pathm = await import('node:path');
4694
+ const osm = await import('node:os');
4695
+ const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim()
4696
+ : typeof args?.sessionId === 'string' ? args.sessionId.trim() : '';
4697
+ let specPath = typeof args?.specPath === 'string' ? args.specPath : '';
4698
+ if (!specPath && sessionId) {
4699
+ const target = this.deps.sessionRegistry.get(sessionId);
4700
+ const adapterObj = target ? this.deps.cliManager.findAdapter(target.providerType, { instanceKey: sessionId })?.adapter : null;
4701
+ const snap = adapterObj && typeof (adapterObj as any).getDebugSnapshot === 'function' ? (adapterObj as any).getDebugSnapshot() : null;
4702
+ specPath = snap?.specPath ?? '';
4703
+ }
4704
+ if (!specPath) return { success: false, error: 'specPath or resolvable targetSessionId required' };
4705
+ // Confine reads to the providers tree, resolving symlinks so a
4706
+ // crafted path can't escape via a symlinked spec file.
4707
+ const safe = resolveSpecPathInProviders(specPath, fsm, pathm, osm);
4708
+ if (!safe.ok) return { success: false, error: safe.error, specPath };
4709
+ try {
4710
+ const content = fsm.readFileSync(safe.path, 'utf8');
4711
+ return { success: true, specPath: safe.path, content };
4712
+ } catch (e) {
4713
+ return { success: false, error: `read failed: ${(e as Error).message}`, specPath };
4714
+ }
4715
+ }
4716
+
4717
+ case 'write_spec_source': {
4718
+ const fsm = await import('node:fs');
4719
+ const pathm = await import('node:path');
4720
+ const osm = await import('node:os');
4721
+ const specPath = typeof args?.specPath === 'string' ? args.specPath : '';
4722
+ const content = typeof args?.content === 'string' ? args.content : '';
4723
+ if (!specPath) return { success: false, error: 'specPath required' };
4724
+ if (!content) return { success: false, error: 'content required' };
4725
+ // Confine writes to the providers tree (symlink-safe — see helper).
4726
+ const safe = resolveSpecPathInProviders(specPath, fsm, pathm, osm);
4727
+ if (!safe.ok) return { success: false, error: safe.error };
4728
+ // Validate JSON + (if v4) FSM structure before writing so a bad
4729
+ // edit can't break the live session — return precise errors.
4730
+ let parsed: unknown;
4731
+ try { parsed = JSON.parse(content); }
4732
+ catch (e) { return { success: false, error: `invalid JSON: ${(e as Error).message}` }; }
4733
+ if ((parsed as any)?.$schema === 'adhdev:cli/spec@4') {
4734
+ const { validateFsmSpec } = await import('../providers/spec/fsm-loader.js');
4735
+ const errs = validateFsmSpec(parsed);
4736
+ if (errs.length) return { success: false, error: 'spec invalid', validationErrors: errs };
4737
+ }
4738
+ try {
4739
+ fsm.writeFileSync(safe.path, content, 'utf8');
4740
+ return { success: true, specPath: safe.path };
4741
+ } catch (e) {
4742
+ return { success: false, error: `write failed: ${(e as Error).message}` };
4743
+ }
4744
+ }
4745
+
4746
+ // ── Validate an in-progress spec (string or object) without writing.
4747
+ // The form builder calls this on every change so Save can stay
4748
+ // disabled while there are structural / reference / regex errors.
4749
+ case 'validate_spec': {
4750
+ let parsed: unknown = args?.spec;
4751
+ if (typeof args?.content === 'string') {
4752
+ try { parsed = JSON.parse(args.content); }
4753
+ catch (e) { return { success: true, valid: false, errors: [`invalid JSON: ${(e as Error).message}`] }; }
4754
+ }
4755
+ if (!parsed || typeof parsed !== 'object') {
4756
+ return { success: true, valid: false, errors: ['spec must be an object or content string'] };
4757
+ }
4758
+ const schema = (parsed as any).$schema;
4759
+ if (schema === 'adhdev:cli/spec@4') {
4760
+ const { validateFsmSpec } = await import('../providers/spec/fsm-loader.js');
4761
+ const errors = validateFsmSpec(parsed);
4762
+ return { success: true, valid: errors.length === 0, errors };
4763
+ }
4764
+ // v1/v3 left to the legacy loader path; the builder is v4-only.
4765
+ return { success: true, valid: false, errors: [`unsupported $schema "${schema}" — form builder is v4-only`] };
4766
+ }
4767
+
4768
+ // ── Evaluate a single condition against a live session's current
4769
+ // screen — powers the editor's "does this match right now?"
4770
+ // preview. Returns the recursive match tree.
4771
+ case 'eval_condition_preview': {
4772
+ const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim()
4773
+ : typeof args?.sessionId === 'string' ? args.sessionId.trim() : '';
4774
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
4775
+ if (!args?.condition || typeof args.condition !== 'object') return { success: false, error: 'condition required' };
4776
+ const target = this.deps.sessionRegistry.get(sessionId);
4777
+ if (!target) return { success: false, error: 'Session not found', sessionId };
4778
+ const adapterObj = this.deps.cliManager.findAdapter(target.providerType, { instanceKey: sessionId })?.adapter as any;
4779
+ const snap = adapterObj && typeof adapterObj.getDebugSnapshot === 'function' ? adapterObj.getDebugSnapshot() : null;
4780
+ if (!snap?.screen) return { success: false, error: 'no live screen for session' };
4781
+ // Reconstruct the sections map the spec would resolve. We pass
4782
+ // the spec's sections so section-scoped regexes resolve the same
4783
+ // way they do at runtime; fall back to the on-disk spec.
4784
+ let sectionsDef: Record<string, unknown> | undefined;
4785
+ try {
4786
+ const fsm2 = await import('node:fs');
4787
+ if (snap.specPath) {
4788
+ const raw = JSON.parse(fsm2.readFileSync(snap.specPath, 'utf8'));
4789
+ sectionsDef = raw?.sections;
4790
+ }
4791
+ } catch { /* fall back to whole-screen matching */ }
4792
+ const { evaluateConditionPreview } = await import('../providers/spec/fsm-evaluator.js');
4793
+ try {
4794
+ const result = evaluateConditionPreview(
4795
+ args.condition,
4796
+ sectionsDef as any,
4797
+ snap.screen,
4798
+ snap.cursorPosition ?? undefined,
4799
+ );
4800
+ return { success: true, result, sections: snap.sections ?? null };
4801
+ } catch (e) {
4802
+ return { success: false, error: `eval failed: ${(e as Error).message}` };
4803
+ }
4804
+ }
4805
+
4806
+ // ── Resolve a sections map against a live session's screen — the
4807
+ // section editor's "test" button. Returns, for each section id,
4808
+ // the line range + the text it captures, so the author can SEE
4809
+ // whether a from_top/until/anchor definition carves the screen
4810
+ // the way they intend. Accepts an in-progress sections map so it
4811
+ // previews unsaved edits.
4812
+ case 'resolve_section_preview': {
4813
+ const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim()
4814
+ : typeof args?.sessionId === 'string' ? args.sessionId.trim() : '';
4815
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
4816
+ if (!args?.sections || typeof args.sections !== 'object') return { success: false, error: 'sections map required' };
4817
+ const target = this.deps.sessionRegistry.get(sessionId);
4818
+ if (!target) return { success: false, error: 'Session not found', sessionId };
4819
+ const adapterObj = this.deps.cliManager.findAdapter(target.providerType, { instanceKey: sessionId })?.adapter as any;
4820
+ const snap = adapterObj && typeof adapterObj.getDebugSnapshot === 'function' ? adapterObj.getDebugSnapshot() : null;
4821
+ if (!snap?.screen) return { success: false, error: 'no live screen for session' };
4822
+ const { resolveSections } = await import('../providers/spec/evaluator.js');
4823
+ try {
4824
+ const lines = String(snap.screen).split('\n').map((l: string) => l.endsWith('\r') ? l.slice(0, -1) : l);
4825
+ const resolved = resolveSections(args.sections as any, lines);
4826
+ return {
4827
+ success: true,
4828
+ screenLineCount: lines.length,
4829
+ sections: resolved.map(s => ({ id: s.id, fromLine: s.fromLine, toLine: s.toLine, text: s.text })),
4830
+ };
4831
+ } catch (e) {
4832
+ return { success: false, error: `resolve failed: ${(e as Error).message}` };
4833
+ }
4834
+ }
4835
+
4644
4836
  // ── User-level coordinator-prompt files (~/.adhdev/coordinator-prompts/).
4645
4837
  // These live on this daemon's filesystem and never sync to the
4646
4838
  // cloud / other daemons — they're per-machine config. The
@@ -1247,29 +1247,39 @@ export class ProviderLoader {
1247
1247
  // Hand the resolved spec path off to route.ts via a hidden field
1248
1248
  // so the routing layer doesn't have to repeat the candidate walk.
1249
1249
  (resolved as any)._resolvedSpecPath = specPath;
1250
- const r = loadSpec(specPath);
1250
+ // Extract control_bar + native_history in a schema-agnostic way.
1251
+ // v3 goes through loadSpec (validates/migrates); v4 (FSM) reads the
1252
+ // header fields directly from JSON since loadSpec only knows v1/v3.
1253
+ let specControls: any[] | undefined;
1254
+ let nh: any | undefined;
1255
+ try {
1256
+ const rawSpec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
1257
+ if (rawSpec?.$schema === 'adhdev:cli/spec@4') {
1258
+ specControls = rawSpec.control_bar;
1259
+ nh = rawSpec.native_history;
1260
+ } else {
1261
+ const r = loadSpec(specPath);
1262
+ if (r.ok) { specControls = r.spec.control_bar; nh = r.spec.native_history; }
1263
+ }
1264
+ } catch { /* unreadable spec — leave controls/native unavailable */ }
1251
1265
  // Stub each control_bar entry as a provider.scripts.<id>. The
1252
1266
  // upstream invoke_provider_script gate checks that the script
1253
1267
  // name exists on provider.scripts before calling adapter.invokeScript;
1254
1268
  // for spec providers the *actual* dispatch happens inside
1255
1269
  // SpecCliAdapter.invokeScript which maps the name to control_bar.
1256
1270
  // The stub is just a presence marker so the gate doesn't reject.
1257
- if (r.ok) {
1258
- const controls = r.spec.control_bar ?? [];
1259
- if (controls.length > 0) {
1260
- resolved.scripts = { ...(resolved.scripts || {}) };
1261
- for (const ctl of controls) {
1262
- if (!(resolved.scripts as any)[ctl.id]) {
1263
- (resolved.scripts as any)[ctl.id] = (..._args: unknown[]) => ({
1264
- __spec_control: true,
1265
- controlId: ctl.id,
1266
- actionType: ctl.action.type,
1267
- });
1268
- }
1271
+ if (specControls && specControls.length > 0) {
1272
+ resolved.scripts = { ...(resolved.scripts || {}) };
1273
+ for (const ctl of specControls) {
1274
+ if (!(resolved.scripts as any)[ctl.id]) {
1275
+ (resolved.scripts as any)[ctl.id] = (..._args: unknown[]) => ({
1276
+ __spec_control: true,
1277
+ controlId: ctl.id,
1278
+ actionType: ctl.action.type,
1279
+ });
1269
1280
  }
1270
1281
  }
1271
1282
  }
1272
- const nh = r.ok ? r.spec.native_history : undefined;
1273
1283
  if (nh) {
1274
1284
  let reader: ((input: any) => any) | null = null;
1275
1285
  let format = 'spec';