@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.
- package/dist/index.js +2238 -1141
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2235 -1139
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/cli-adapter.d.ts +1 -0
- package/dist/providers/spec/driver.d.ts +50 -1
- package/dist/providers/spec/evaluator.d.ts +17 -1
- package/dist/providers/spec/fsm-driver.d.ts +110 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +91 -0
- package/package.json +1 -1
- package/src/commands/router.ts +192 -0
- package/src/providers/provider-loader.ts +24 -14
- package/src/providers/spec/cli-adapter.ts +62 -14
- package/src/providers/spec/driver.ts +55 -4
- package/src/providers/spec/evaluator.ts +10 -6
- package/src/providers/spec/fsm-driver.ts +663 -0
- package/src/providers/spec/fsm-evaluator.ts +255 -0
- package/src/providers/spec/fsm-loader.ts +102 -0
- package/src/providers/spec/fsm-types.ts +184 -0
|
@@ -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
package/src/commands/router.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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';
|