@adhdev/daemon-core 0.9.82-rc.24 → 0.9.82-rc.240
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/boot/daemon-lifecycle.d.ts +1 -0
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/chat/subscription-updates.d.ts +1 -0
- package/dist/cli-adapter-types.d.ts +23 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +106 -63
- package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +29 -0
- package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +3 -3
- package/dist/cli-adapters/terminal-backends/types.d.ts +1 -2
- package/dist/cli-adapters/terminal-screen.d.ts +2 -11
- package/dist/commands/cli-manager.d.ts +2 -1
- package/dist/commands/handler.d.ts +110 -0
- package/dist/commands/mesh-coordinator.d.ts +85 -1
- package/dist/commands/router.d.ts +30 -1
- package/dist/config/chat-history.d.ts +9 -0
- package/dist/config/config.d.ts +5 -0
- package/dist/config/mesh-config.d.ts +72 -1
- package/dist/git/git-commands.d.ts +9 -1
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/git/git-types.d.ts +2 -0
- package/dist/index.d.ts +47 -11
- package/dist/index.js +30449 -11360
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +30282 -11285
- package/dist/index.mjs.map +1 -1
- package/dist/installer.d.ts +1 -4
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/launch.d.ts +1 -1
- package/dist/logging/async-batch-writer.d.ts +10 -0
- package/dist/mesh/contracts.d.ts +164 -0
- package/dist/mesh/coordinator-prompt.d.ts +36 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +90 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
- package/dist/mesh/mesh-events-pending.d.ts +33 -0
- package/dist/mesh/mesh-events-stale.d.ts +40 -0
- package/dist/mesh/mesh-events-utils.d.ts +14 -0
- package/dist/mesh/mesh-events.d.ts +5 -40
- package/dist/mesh/mesh-fast-forward.d.ts +41 -0
- package/dist/mesh/mesh-host-ownership.d.ts +9 -0
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
- package/dist/mesh/mesh-ledger.d.ts +77 -1
- package/dist/mesh/mesh-missions.d.ts +58 -0
- package/dist/mesh/mesh-refine-status.d.ts +26 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-runtime-store.d.ts +324 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-work-queue.d.ts +137 -6
- package/dist/mesh/preview-freshness.d.ts +18 -0
- package/dist/mesh/refine-config.d.ts +215 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
- package/dist/providers/acp-provider-instance.d.ts +2 -0
- package/dist/providers/approval-utils.d.ts +13 -0
- package/dist/providers/cli-provider-instance.d.ts +40 -3
- package/dist/providers/contracts.d.ts +130 -6
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/dispatcher.d.ts +27 -0
- package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
- package/dist/providers/native-history/index.d.ts +13 -0
- package/dist/providers/provider-instance-manager.d.ts +13 -0
- package/dist/providers/provider-instance.d.ts +13 -1
- package/dist/providers/provider-loader.d.ts +26 -4
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/dist/providers/spec/adapter.d.ts +52 -0
- package/dist/providers/spec/cli-adapter.d.ts +102 -0
- package/dist/providers/spec/evaluator.d.ts +23 -0
- package/dist/providers/spec/fsm-driver.d.ts +231 -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/dist/providers/spec/native-history-executor.d.ts +41 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +129 -0
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/providers/types/interactive-prompt.d.ts +48 -0
- package/dist/repo-mesh-types.d.ts +126 -1
- package/dist/sessions/registry.d.ts +6 -0
- package/dist/shared-types-extra.d.ts +2 -4
- package/dist/shared-types.d.ts +42 -1
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/dist/status/reporter.d.ts +2 -0
- package/dist/status/snapshot.d.ts +1 -0
- package/dist/types.d.ts +5 -0
- package/package.json +6 -3
- package/src/agent-stream/poller.ts +2 -3
- package/src/boot/daemon-lifecycle.ts +22 -10
- package/src/boot/process-hardening.ts +89 -0
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +20 -1
- package/src/cli-adapter-types.d.ts +1 -0
- package/src/cli-adapter-types.ts +22 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1162 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +673 -1139
- package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
- package/src/cli-adapters/provider-cli-parse.ts +13 -0
- package/src/cli-adapters/provider-cli-runtime.ts +3 -1
- package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.ts +59 -11
- package/src/cli-adapters/raw-terminal-io.ts +252 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +35 -29
- package/src/cli-adapters/terminal-backends/types.ts +1 -3
- package/src/cli-adapters/terminal-screen.ts +11 -81
- package/src/commands/chat-commands.ts +1806 -60
- package/src/commands/cli-manager.ts +286 -14
- package/src/commands/handler.ts +809 -2
- package/src/commands/mesh-coordinator.ts +332 -122
- package/src/commands/router.ts +4598 -535
- package/src/config/chat-history.ts +95 -24
- package/src/config/config.ts +12 -0
- package/src/config/mesh-config.ts +303 -3
- package/src/config/recent-activity.ts +8 -2
- package/src/daemon/dev-auto-implement.ts +3 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/detection/ide-detector.ts +26 -16
- package/src/git/git-commands.ts +42 -12
- package/src/git/git-diff.ts +53 -0
- package/src/git/git-status.ts +35 -6
- package/src/git/git-types.ts +2 -0
- package/src/git/git-worktree.ts +8 -1
- package/src/index.ts +127 -10
- package/src/installer.d.ts +1 -1
- package/src/installer.ts +8 -6
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/launch.d.ts +1 -1
- package/src/launch.ts +37 -28
- package/src/logging/async-batch-writer.ts +55 -0
- package/src/logging/logger.ts +2 -1
- package/src/mesh/contracts.ts +329 -0
- package/src/mesh/coordinator-prompt.ts +219 -31
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +437 -0
- package/src/mesh/mesh-delivery-policy.ts +315 -0
- package/src/mesh/mesh-events-coordinator.ts +1523 -0
- package/src/mesh/mesh-events-pending.ts +371 -0
- package/src/mesh/mesh-events-stale.ts +283 -0
- package/src/mesh/mesh-events-utils.ts +175 -0
- package/src/mesh/mesh-events.ts +24 -959
- package/src/mesh/mesh-fast-forward.ts +438 -0
- package/src/mesh/mesh-host-ownership.ts +73 -0
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
- package/src/mesh/mesh-ledger.ts +587 -102
- package/src/mesh/mesh-missions.ts +151 -0
- package/src/mesh/mesh-refine-status.ts +144 -0
- package/src/mesh/mesh-review-inbox.ts +307 -0
- package/src/mesh/mesh-runtime-store.ts +1305 -0
- package/src/mesh/mesh-task-stats.ts +154 -0
- package/src/mesh/mesh-work-queue.ts +454 -143
- package/src/mesh/preview-freshness.ts +118 -0
- package/src/mesh/refine-config.ts +403 -0
- package/src/mesh/worktree-bootstrap-config.ts +326 -0
- package/src/providers/acp-provider-instance.ts +15 -1
- package/src/providers/approval-utils.d.ts +4 -0
- package/src/providers/approval-utils.ts +47 -5
- package/src/providers/chat-message-normalization.ts +4 -11
- package/src/providers/cli-provider-instance.ts +850 -71
- package/src/providers/contracts.d.ts +55 -0
- package/src/providers/contracts.ts +141 -6
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/ide-provider-instance.ts +19 -5
- package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +479 -0
- package/src/providers/native-history/dispatcher.ts +340 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
- package/src/providers/native-history/index.ts +30 -0
- package/src/providers/provider-instance-manager.ts +30 -0
- package/src/providers/provider-instance.ts +10 -1
- package/src/providers/provider-loader.ts +584 -50
- package/src/providers/provider-schema.ts +87 -14
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/read-chat-contract.ts +76 -16
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +152 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +365 -0
- package/src/providers/sdk/v1/types/common/index.ts +229 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- package/src/providers/sdk/v1/validators/taint.ts +309 -0
- package/src/providers/spec/adapter.ts +138 -0
- package/src/providers/spec/cli-adapter.ts +646 -0
- package/src/providers/spec/evaluator.ts +321 -0
- package/src/providers/spec/fsm-driver.ts +749 -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
- package/src/providers/spec/native-history-executor.ts +943 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/types.ts +173 -0
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/types/interactive-prompt.ts +425 -0
- package/src/providers/version-archive.ts +38 -20
- package/src/repo-mesh-types.ts +138 -1
- package/src/sessions/registry.ts +6 -0
- package/src/shared-types-extra.ts +2 -4
- package/src/shared-types.ts +45 -1
- package/src/status/builders.ts +26 -6
- package/src/status/normalize.ts +2 -0
- package/src/status/reporter.ts +15 -0
- package/src/status/snapshot.ts +84 -25
- package/src/system/host-memory.ts +29 -12
- package/src/types.ts +5 -0
- package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
- package/dist/mesh/mesh-sync.d.ts +0 -53
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
- package/src/mesh/mesh-sync.ts +0 -111
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FsmDriver — runs an adhdev:cli/spec@4 finite state machine against a PTY.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in compatible with SpecDriver's public surface (subscribe / dispatch /
|
|
5
|
+
* start / snapshot / getStateHistory / getDebugState / …) so the cli-adapter
|
|
6
|
+
* can use either driver behind one interface. The difference is entirely
|
|
7
|
+
* internal: instead of a stack of hard-coded debounce layers, this driver
|
|
8
|
+
* holds ONE piece of state — the current FSM node — and on every screen change
|
|
9
|
+
* asks the FSM evaluator "which outgoing transition fires?". All timing
|
|
10
|
+
* (startup grace, busy hold, completion stability) is expressed in the spec as
|
|
11
|
+
* transition guards (min_hold_ms) and time conditions (elapsed_ms / stable_ms).
|
|
12
|
+
*
|
|
13
|
+
* Because the engine carries no CLI knowledge, the only way it can be "wrong"
|
|
14
|
+
* is if a spec's transitions are wrong — and that is fully inspectable via
|
|
15
|
+
* getFsmDebug(), which reports every outgoing transition with its per-condition
|
|
16
|
+
* match result and countdown. That is the contract: debug the spec, not the
|
|
17
|
+
* engine.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
|
|
25
|
+
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
26
|
+
import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
|
|
27
|
+
import {
|
|
28
|
+
resolveSections, sectionText, extractTitle, extractButtonsFromRule,
|
|
29
|
+
type ResolvedSection, type TraceEntry,
|
|
30
|
+
} from './evaluator.js';
|
|
31
|
+
import { evaluateFsm, type FsmClock, type TransitionEval } from './fsm-evaluator.js';
|
|
32
|
+
import {
|
|
33
|
+
type CliSpecV4, type FsmState, type FsmTransition,
|
|
34
|
+
initialState, stateById, statusForState, outgoingTransitions,
|
|
35
|
+
} from './fsm-types.js';
|
|
36
|
+
import { loadFsmSpec } from './fsm-loader.js';
|
|
37
|
+
import type { Control, DelegateTrigger } from './types.js';
|
|
38
|
+
import { LOG } from '../../logging/logger.js';
|
|
39
|
+
|
|
40
|
+
// ── Shared driver types (formerly in driver.ts) ───────────────────────────
|
|
41
|
+
|
|
42
|
+
export type DashboardEvent =
|
|
43
|
+
| { kind: 'pty_data'; chunk: string }
|
|
44
|
+
| { kind: 'state_changed'; state: { id: string; label: string; title: string | null };
|
|
45
|
+
modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
|
|
46
|
+
controls: { id: string; label: string; action_type: string }[] }
|
|
47
|
+
| { kind: 'notification'; id: string; title: string; body: string }
|
|
48
|
+
| { kind: 'delegate'; id: string; task: string }
|
|
49
|
+
| { kind: 'spec_trace'; entries: TraceEntry[] }
|
|
50
|
+
| { kind: 'exit'; exit_code: number }
|
|
51
|
+
| { kind: 'spec_error'; errors: string[] };
|
|
52
|
+
|
|
53
|
+
export type DashboardCommand =
|
|
54
|
+
| { kind: 'send_message'; text: string }
|
|
55
|
+
| { kind: 'pty_write'; data: string }
|
|
56
|
+
| { kind: 'click_control'; control_id: string; payload?: unknown }
|
|
57
|
+
| { kind: 'click_modal_button'; index: number }
|
|
58
|
+
| { kind: 'attach_image'; blob: string; mime: string }
|
|
59
|
+
| { kind: 'resize'; cols: number; rows: number }
|
|
60
|
+
| { kind: 'cancel' }
|
|
61
|
+
| { kind: 'shutdown' };
|
|
62
|
+
|
|
63
|
+
export interface DriverHistoryEntry {
|
|
64
|
+
stateId: string;
|
|
65
|
+
label: string;
|
|
66
|
+
at: number;
|
|
67
|
+
durationMs: number;
|
|
68
|
+
reason: string;
|
|
69
|
+
matchedStateId?: string;
|
|
70
|
+
matchedRules?: string[];
|
|
71
|
+
debounceKind?: string;
|
|
72
|
+
idleHoldMs?: number;
|
|
73
|
+
busyHoldMs?: number;
|
|
74
|
+
via?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ISpecDriver {
|
|
78
|
+
subscribe(listener: (ev: DashboardEvent) => void): () => void;
|
|
79
|
+
start(): void;
|
|
80
|
+
dispatch(cmd: DashboardCommand): void;
|
|
81
|
+
snapshot(): string;
|
|
82
|
+
getCursorPosition(): { row: number; col: number };
|
|
83
|
+
getScreen(): string;
|
|
84
|
+
getSpecPath(): string;
|
|
85
|
+
shutdown(): void;
|
|
86
|
+
getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
|
|
87
|
+
getSections(): Array<{ id: string; text: string }> | null;
|
|
88
|
+
getLastBusyAt(): number;
|
|
89
|
+
hasIdleHoldPending(): boolean;
|
|
90
|
+
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
91
|
+
getFsmDebug?(): unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SpecDriverOpts {
|
|
95
|
+
specPath: string;
|
|
96
|
+
workingDir: string;
|
|
97
|
+
extraEnv?: Record<string, string>;
|
|
98
|
+
cols?: number;
|
|
99
|
+
rows?: number;
|
|
100
|
+
hotReload?: boolean;
|
|
101
|
+
emitTrace?: boolean;
|
|
102
|
+
transportFactory?: PtyTransportFactory;
|
|
103
|
+
extraCliArgs?: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countNewlines(s: string): number {
|
|
107
|
+
let n = 0;
|
|
108
|
+
for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SUBMIT_DELAY_FLOOR_MS = 200;
|
|
113
|
+
|
|
114
|
+
export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
|
|
115
|
+
const lines = countNewlines(text);
|
|
116
|
+
const linesBonus = Math.min(800, lines * 80);
|
|
117
|
+
const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
|
|
118
|
+
return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function guessExt(mime: string): string {
|
|
122
|
+
if (/png/i.test(mime)) return '.png';
|
|
123
|
+
if (/jpe?g/i.test(mime)) return '.jpg';
|
|
124
|
+
if (/gif/i.test(mime)) return '.gif';
|
|
125
|
+
if (/webp/i.test(mime)) return '.webp';
|
|
126
|
+
return '.bin';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
interface ModalSnapshot {
|
|
132
|
+
title: string | null;
|
|
133
|
+
buttons: { index: number; label: string; key: string }[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface VisibleControl {
|
|
137
|
+
id: string;
|
|
138
|
+
label: string;
|
|
139
|
+
actionType: 'send_keys' | 'open_picker' | 'attach_image';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type HistoryEntry = DriverHistoryEntry;
|
|
143
|
+
|
|
144
|
+
/** Per-state evaluation snapshot (mirrors v3 SpecEvaluation shape for the
|
|
145
|
+
* parts the cli-adapter / panel consume). */
|
|
146
|
+
interface CurrentEval {
|
|
147
|
+
state: { id: string; label: string; title: string | null };
|
|
148
|
+
modal: ModalSnapshot | null;
|
|
149
|
+
controls: VisibleControl[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class FsmDriver implements ISpecDriver {
|
|
153
|
+
private spec!: CliSpecV4;
|
|
154
|
+
private adapter: TerminalAdapter;
|
|
155
|
+
private listeners = new Set<(ev: DashboardEvent) => void>();
|
|
156
|
+
|
|
157
|
+
// ── The entire FSM state: which node we're in, and when we entered it.
|
|
158
|
+
private currentStateId = '';
|
|
159
|
+
private stateEnteredAt = 0;
|
|
160
|
+
|
|
161
|
+
// ── Clock bookkeeping for time conditions.
|
|
162
|
+
private startedAtMs = 0;
|
|
163
|
+
private prevScreenLines: string[] = [];
|
|
164
|
+
/** Per cursor_above region (key: cursor_above, -1 = whole screen) → last
|
|
165
|
+
* time that region's content changed. Drives stable_ms conditions. */
|
|
166
|
+
private regionLastChangedAt = new Map<number, number>();
|
|
167
|
+
/** Timer that re-runs evaluate() when a time-condition would flip true
|
|
168
|
+
* with no PTY frame to trigger it. */
|
|
169
|
+
private wakeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
170
|
+
|
|
171
|
+
private currentEval: CurrentEval | null = null;
|
|
172
|
+
private stateHistory: HistoryEntry[] = [];
|
|
173
|
+
private prevStateAt = 0;
|
|
174
|
+
|
|
175
|
+
// ── send_message queueing until the machine first reaches a non-initial,
|
|
176
|
+
// non-busy ("ready") state — same contract as v3's idleSeenOnce.
|
|
177
|
+
private readySeenOnce = false;
|
|
178
|
+
private pendingSends: string[] = [];
|
|
179
|
+
|
|
180
|
+
private pickerInProgress: { control_id: string; spec: Control } | null = null;
|
|
181
|
+
private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
182
|
+
private specWatcher: fs.FSWatcher | null = null;
|
|
183
|
+
/** Last full FSM evaluation, kept for the debugger. */
|
|
184
|
+
private lastFsmEval: ReturnType<typeof evaluateFsm> | null = null;
|
|
185
|
+
|
|
186
|
+
constructor(private readonly opts: SpecDriverOpts) {
|
|
187
|
+
this.loadSpecOrThrow();
|
|
188
|
+
this.adapter = new TerminalAdapter(
|
|
189
|
+
this.buildAdapterOpts(),
|
|
190
|
+
{
|
|
191
|
+
init: () => this.emitInitialState(),
|
|
192
|
+
on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
|
|
193
|
+
on_screen_changed: () => this.reevaluate(),
|
|
194
|
+
on_exit: ({ exitCode }) => this.handleExit(exitCode),
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
if (this.opts.hotReload !== false) this.armSpecWatcher();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
subscribe(listener: (ev: DashboardEvent) => void): () => void {
|
|
201
|
+
this.listeners.add(listener);
|
|
202
|
+
return () => { this.listeners.delete(listener); };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
start(): void {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
this.startedAtMs = now;
|
|
208
|
+
const init = initialState(this.spec);
|
|
209
|
+
this.currentStateId = init.id;
|
|
210
|
+
this.stateEnteredAt = now;
|
|
211
|
+
this.prevStateAt = now;
|
|
212
|
+
this.adapter.start();
|
|
213
|
+
// The initial state may have a purely time-based exit (elapsed_ms);
|
|
214
|
+
// schedule a wake so we leave it even if the PTY goes quiet.
|
|
215
|
+
this.scheduleWakeForState();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
dispatch(cmd: DashboardCommand): void {
|
|
219
|
+
switch (cmd.kind) {
|
|
220
|
+
case 'send_message': this.handleSendMessage(cmd.text); return;
|
|
221
|
+
case 'pty_write': this.adapter.send_keys(cmd.data); return;
|
|
222
|
+
case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
|
|
223
|
+
case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
|
|
224
|
+
case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
|
|
225
|
+
case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
|
|
226
|
+
case 'cancel': this.adapter.send_keys('\x03'); return;
|
|
227
|
+
case 'shutdown': this.shutdown(); return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
snapshot(): string { return this.adapter.snapshot(); }
|
|
232
|
+
getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
|
|
233
|
+
getScreen(): string { return this.adapter.snapshot(); }
|
|
234
|
+
getSpecPath(): string { return this.opts.specPath; }
|
|
235
|
+
|
|
236
|
+
shutdown(): void {
|
|
237
|
+
for (const t of this.delegateTimers.values()) clearTimeout(t);
|
|
238
|
+
this.delegateTimers.clear();
|
|
239
|
+
if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
|
|
240
|
+
this.specWatcher?.close();
|
|
241
|
+
this.adapter.kill();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Debug surface (the whole reason for the rewrite) ──────────────────
|
|
245
|
+
//
|
|
246
|
+
// getFsmDebug() answers, for the CURRENT instant: which state am I in,
|
|
247
|
+
// how long have I been here, and for every outgoing transition — does its
|
|
248
|
+
// guard pass right now, and if not, which sub-condition is blocking and
|
|
249
|
+
// how many ms until it would flip. No screenshots required.
|
|
250
|
+
|
|
251
|
+
getFsmDebug(): {
|
|
252
|
+
currentState: string;
|
|
253
|
+
label: string;
|
|
254
|
+
stateAgeMs: number;
|
|
255
|
+
status: string;
|
|
256
|
+
cursor: { row: number; col: number };
|
|
257
|
+
transitions: TransitionEval[];
|
|
258
|
+
} {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const cursor = this.adapter.getCursorPosition();
|
|
261
|
+
const screen = this.adapter.snapshot();
|
|
262
|
+
const ev = this.evalFsmNow(screen, cursor, now);
|
|
263
|
+
const state = stateById(this.spec, this.currentStateId);
|
|
264
|
+
return {
|
|
265
|
+
currentState: this.currentStateId,
|
|
266
|
+
label: state?.label ?? this.currentStateId,
|
|
267
|
+
stateAgeMs: now - this.stateEnteredAt,
|
|
268
|
+
status: state ? statusForState(state) : 'idle',
|
|
269
|
+
cursor,
|
|
270
|
+
transitions: ev.transitions,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
|
|
275
|
+
getSections(): Array<{ id: string; text: string }> | null {
|
|
276
|
+
try {
|
|
277
|
+
const screen = this.adapter.snapshot();
|
|
278
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
279
|
+
return resolveSections(this.spec.sections ?? {}, lines).map(s => ({ id: s.id, text: s.text }));
|
|
280
|
+
} catch { return null; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** v3-compat shims so the cli-adapter's existing debug snapshot keeps
|
|
284
|
+
* working without branching on driver type. */
|
|
285
|
+
getLastBusyAt(): number {
|
|
286
|
+
// Approximate: time we entered a generating-status state.
|
|
287
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
288
|
+
return st && statusForState(st) === 'generating' ? this.stateEnteredAt : 0;
|
|
289
|
+
}
|
|
290
|
+
hasIdleHoldPending(): boolean {
|
|
291
|
+
// FSM has no separate idle-hold timer; min_hold_ms is the analog.
|
|
292
|
+
// Report true while any outgoing transition is hold-blocked.
|
|
293
|
+
return (this.lastFsmEval?.transitions ?? []).some(t => !t.holdSatisfied && t.condResult);
|
|
294
|
+
}
|
|
295
|
+
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
|
|
296
|
+
// Surface the busy→ready transition's stable countdown, if any, so the
|
|
297
|
+
// existing panel field stays meaningful.
|
|
298
|
+
const out = outgoingTransitions(this.spec, this.currentStateId);
|
|
299
|
+
const toReady = this.lastFsmEval?.transitions.find((t, i) => {
|
|
300
|
+
const st = stateById(this.spec, out[i]?.to ?? '');
|
|
301
|
+
return st && statusForState(st) === 'idle';
|
|
302
|
+
});
|
|
303
|
+
if (!toReady || !toReady.cond) return null;
|
|
304
|
+
const stable = findStable(toReady.cond);
|
|
305
|
+
if (!stable) return null;
|
|
306
|
+
return { active: true, ageMs: 0, holdMs: stable.totalMs, forceAfterMs: 0 };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ────────────────────────────────────────────────────────────────────
|
|
310
|
+
// Loading & adapter wiring
|
|
311
|
+
// ────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
private loadSpecOrThrow(): void {
|
|
314
|
+
const res = loadFsmSpec(this.opts.specPath);
|
|
315
|
+
if (!res.ok) throw new Error(`fsm spec invalid: ${res.errors.join('; ')}`);
|
|
316
|
+
this.spec = res.spec;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private buildAdapterOpts(): TerminalAdapterOpts {
|
|
320
|
+
const baseArgs = this.spec.spawn_args ?? [];
|
|
321
|
+
const extra = this.opts.extraCliArgs ?? [];
|
|
322
|
+
return {
|
|
323
|
+
binary: this.spec.binary,
|
|
324
|
+
args: [...baseArgs, ...extra],
|
|
325
|
+
cwd: this.opts.workingDir,
|
|
326
|
+
env: { ...(this.spec.env ?? {}), ...(this.opts.extraEnv ?? {}) },
|
|
327
|
+
cols: this.opts.cols ?? DEFAULT_SESSION_HOST_COLS,
|
|
328
|
+
rows: this.opts.rows ?? DEFAULT_SESSION_HOST_ROWS,
|
|
329
|
+
transportFactory: this.opts.transportFactory,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private armSpecWatcher(): void {
|
|
334
|
+
try {
|
|
335
|
+
const dir = path.dirname(this.opts.specPath);
|
|
336
|
+
const base = path.basename(this.opts.specPath);
|
|
337
|
+
this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
|
|
338
|
+
if (filename && filename !== base) return;
|
|
339
|
+
const res = loadFsmSpec(this.opts.specPath);
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
this.emit({ kind: 'spec_error', errors: res.errors });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.spec = res.spec;
|
|
345
|
+
LOG.info('FsmDriver', `[${this.specTag()}] spec hot-reloaded`);
|
|
346
|
+
// Re-evaluate immediately with the new transitions.
|
|
347
|
+
this.reevaluate(true);
|
|
348
|
+
});
|
|
349
|
+
} catch { /* best-effort */ }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private emitInitialState(): void {
|
|
353
|
+
this.reevaluate(true);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ────────────────────────────────────────────────────────────────────
|
|
357
|
+
// Core: evaluate the FSM and commit transitions
|
|
358
|
+
// ────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
private buildClock(now: number): FsmClock {
|
|
361
|
+
return {
|
|
362
|
+
now,
|
|
363
|
+
stateEnteredAt: this.stateEnteredAt,
|
|
364
|
+
regionLastChangedAt: this.regionLastChangedAt,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private evalFsmNow(screen: string, cursor: { row: number; col: number }, now: number) {
|
|
369
|
+
const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined;
|
|
370
|
+
return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private reevaluate(forceEmit = false): void {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
const screen = this.adapter.snapshot();
|
|
376
|
+
const cursor = this.adapter.getCursorPosition();
|
|
377
|
+
const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
378
|
+
|
|
379
|
+
// Track per-region change timestamps for stable_ms conditions BEFORE
|
|
380
|
+
// we overwrite prevScreenLines.
|
|
381
|
+
this.trackRegionChanges(currentLines, cursor, now);
|
|
382
|
+
|
|
383
|
+
const ev = this.evalFsmNow(screen, cursor, now);
|
|
384
|
+
this.lastFsmEval = ev;
|
|
385
|
+
this.prevScreenLines = currentLines;
|
|
386
|
+
|
|
387
|
+
if (ev.fired) {
|
|
388
|
+
this.commitTransition(ev.fired, now);
|
|
389
|
+
// After a transition, immediately re-derive controls/modal for the
|
|
390
|
+
// new state and emit. Re-run once so a chain like approval→busy
|
|
391
|
+
// that's already satisfied doesn't wait for the next PTY frame.
|
|
392
|
+
this.emitStateChanged(forceEmit);
|
|
393
|
+
this.scheduleWakeForState();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// No transition — refresh modal/controls (content inside the same
|
|
398
|
+
// state can still change, e.g. modal title/buttons) and emit if changed.
|
|
399
|
+
this.emitStateChanged(forceEmit);
|
|
400
|
+
// Schedule a wake for the soonest pending time-condition.
|
|
401
|
+
this.scheduleWakeForState();
|
|
402
|
+
// Drain queued sends once we first reach a "ready" state.
|
|
403
|
+
this.maybeMarkReady();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private commitTransition(fired: TransitionEval, now: number): void {
|
|
407
|
+
const from = this.currentStateId;
|
|
408
|
+
this.currentStateId = fired.to;
|
|
409
|
+
this.stateEnteredAt = now;
|
|
410
|
+
// Region change timestamps are relative to the previous state's
|
|
411
|
+
// activity; reset so stable_ms in the new state measures from entry.
|
|
412
|
+
this.regionLastChangedAt.clear();
|
|
413
|
+
this.pushHistory(fired.to, stateById(this.spec, fired.to)?.label ?? fired.to, {
|
|
414
|
+
reason: 'transition',
|
|
415
|
+
via: `${from}→${fired.to}`,
|
|
416
|
+
matchedRules: summarizeTransition(fired),
|
|
417
|
+
});
|
|
418
|
+
LOG.info('FsmDriver', `[${this.specTag()}] ${from} → ${fired.to} (${fired.label})`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Re-derive the visible modal + controls for the current state and emit a
|
|
422
|
+
* state_changed if anything differs from the last emit. */
|
|
423
|
+
private emitStateChanged(forceEmit: boolean): void {
|
|
424
|
+
const state = stateById(this.spec, this.currentStateId);
|
|
425
|
+
if (!state) return;
|
|
426
|
+
const screen = this.adapter.snapshot();
|
|
427
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
428
|
+
const sections = resolveSections(this.spec.sections ?? {}, lines);
|
|
429
|
+
|
|
430
|
+
const modal = this.deriveModal(state, sections, lines.join('\n'));
|
|
431
|
+
const controls = this.deriveControls(state.id);
|
|
432
|
+
const title = modal?.title ?? this.deriveTitle(state, sections, lines.join('\n'));
|
|
433
|
+
|
|
434
|
+
const next: CurrentEval = {
|
|
435
|
+
state: { id: state.id, label: state.label, title },
|
|
436
|
+
modal,
|
|
437
|
+
controls,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const changed = forceEmit
|
|
441
|
+
|| !this.currentEval
|
|
442
|
+
|| this.currentEval.state.id !== next.state.id
|
|
443
|
+
|| this.currentEval.state.title !== next.state.title
|
|
444
|
+
|| !sameModal(this.currentEval.modal, next.modal)
|
|
445
|
+
|| !sameControls(this.currentEval.controls, next.controls);
|
|
446
|
+
|
|
447
|
+
this.currentEval = next;
|
|
448
|
+
if (this.pickerInProgress) this.tryAdvancePicker(screen);
|
|
449
|
+
|
|
450
|
+
if (changed) {
|
|
451
|
+
this.emit({
|
|
452
|
+
kind: 'state_changed',
|
|
453
|
+
state: next.state,
|
|
454
|
+
modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
|
|
455
|
+
controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
|
|
456
|
+
});
|
|
457
|
+
this.fireNotifications(state.id, title);
|
|
458
|
+
this.armOrCancelDelegateTimers(state.id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private deriveModal(state: FsmState, sections: ResolvedSection[], fullScreen: string): ModalSnapshot | null {
|
|
463
|
+
const rule = state.extract?.buttons;
|
|
464
|
+
if (!rule) return null;
|
|
465
|
+
const hay = sectionText(sections, rule.section, fullScreen);
|
|
466
|
+
const minCount = rule.min_count ?? 2;
|
|
467
|
+
const buttons = extractButtonsFromRule(rule, hay);
|
|
468
|
+
if (buttons.length < minCount) return null;
|
|
469
|
+
const title = this.deriveTitle(state, sections, fullScreen);
|
|
470
|
+
return { title, buttons };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private deriveTitle(state: FsmState, sections: ResolvedSection[], fullScreen: string): string | null {
|
|
474
|
+
const rule = state.extract?.title;
|
|
475
|
+
if (!rule) return null;
|
|
476
|
+
return extractTitle(rule, sections, fullScreen);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private deriveControls(stateId: string): VisibleControl[] {
|
|
480
|
+
const out: VisibleControl[] = [];
|
|
481
|
+
for (const c of this.spec.control_bar ?? []) {
|
|
482
|
+
if (c.visible_when_state && !c.visible_when_state.includes(stateId)) continue;
|
|
483
|
+
out.push({ id: c.id, label: c.label, actionType: c.action.type });
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Track which cursor_above regions changed since the previous frame so
|
|
489
|
+
* stable_ms conditions can measure quiet time. We record every region
|
|
490
|
+
* size referenced by a stable_ms condition in the spec, plus the whole
|
|
491
|
+
* screen (-1). */
|
|
492
|
+
private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
|
|
493
|
+
if (this.prevScreenLines.length === 0) return;
|
|
494
|
+
const sizes = this.stableRegionSizes();
|
|
495
|
+
for (const size of sizes) {
|
|
496
|
+
let cur: string; let prev: string;
|
|
497
|
+
if (size < 0) {
|
|
498
|
+
cur = currentLines.join('\n');
|
|
499
|
+
prev = this.prevScreenLines.join('\n');
|
|
500
|
+
} else {
|
|
501
|
+
const start = Math.max(0, cursor.row - size);
|
|
502
|
+
cur = currentLines.slice(start, cursor.row).join('\n');
|
|
503
|
+
prev = this.prevScreenLines.slice(start, cursor.row).join('\n');
|
|
504
|
+
}
|
|
505
|
+
if (cur !== prev) this.regionLastChangedAt.set(size, now);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** All cursor_above region sizes referenced by stable_ms conditions in the
|
|
510
|
+
* current state's outgoing transitions, plus whole-screen. Cached lazily
|
|
511
|
+
* per spec load would be nicer but the set is tiny. */
|
|
512
|
+
private stableRegionSizes(): Set<number> {
|
|
513
|
+
const sizes = new Set<number>([-1]);
|
|
514
|
+
for (const t of outgoingTransitions(this.spec, this.currentStateId)) {
|
|
515
|
+
collectStableSizes(t.when, sizes);
|
|
516
|
+
}
|
|
517
|
+
return sizes;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Schedule a re-evaluation for the soonest pending time-condition on any
|
|
521
|
+
* outgoing transition (elapsed_ms / stable_ms / min_hold_ms). Without
|
|
522
|
+
* this, a state whose only exit is time-based would never leave once the
|
|
523
|
+
* PTY goes quiet. */
|
|
524
|
+
private scheduleWakeForState(): void {
|
|
525
|
+
if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
|
|
526
|
+
const ev = this.lastFsmEval;
|
|
527
|
+
if (!ev) return;
|
|
528
|
+
let soonest = Infinity;
|
|
529
|
+
for (const t of ev.transitions) {
|
|
530
|
+
if (t.fires) continue;
|
|
531
|
+
if (!t.holdSatisfied) soonest = Math.min(soonest, t.holdRemainingMs);
|
|
532
|
+
const condRemain = t.cond ? t.cond.remainingMs ?? Infinity : Infinity;
|
|
533
|
+
// Only treat the cond countdown as a wake source when the rest of
|
|
534
|
+
// the guard (hold) is already or will be satisfied.
|
|
535
|
+
if (Number.isFinite(condRemain) && condRemain > 0) soonest = Math.min(soonest, condRemain);
|
|
536
|
+
}
|
|
537
|
+
if (!Number.isFinite(soonest)) return;
|
|
538
|
+
this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.reevaluate(); }, Math.max(soonest + 30, 50));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private maybeMarkReady(): void {
|
|
542
|
+
if (this.readySeenOnce) return;
|
|
543
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
544
|
+
if (!st) return;
|
|
545
|
+
// "Ready" = a non-initial state whose status is idle (the prompt is up).
|
|
546
|
+
if (!st.initial && statusForState(st) === 'idle') {
|
|
547
|
+
this.readySeenOnce = true;
|
|
548
|
+
const queued = this.pendingSends.splice(0);
|
|
549
|
+
for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ────────────────────────────────────────────────────────────────────
|
|
554
|
+
// Notifications & delegates
|
|
555
|
+
// ────────────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
private fireNotifications(stateId: string, title: string | null): void {
|
|
558
|
+
for (const n of this.spec.notifications ?? []) {
|
|
559
|
+
if (n.when_state !== stateId) continue;
|
|
560
|
+
const body = (n.body ?? '').replace(/\{state\.title\}/g, title ?? '');
|
|
561
|
+
this.emit({ kind: 'notification', id: n.id, title: n.title, body });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private armOrCancelDelegateTimers(currentStateId: string): void {
|
|
566
|
+
for (const d of this.spec.delegate ?? []) {
|
|
567
|
+
const armed = this.delegateTimers.has(d.id);
|
|
568
|
+
const shouldFire = d.when_state === currentStateId;
|
|
569
|
+
if (shouldFire && !armed) {
|
|
570
|
+
const delay = d.after_duration_ms ?? 0;
|
|
571
|
+
const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
|
|
572
|
+
this.delegateTimers.set(d.id, t);
|
|
573
|
+
} else if (!shouldFire && armed) {
|
|
574
|
+
clearTimeout(this.delegateTimers.get(d.id)!);
|
|
575
|
+
this.delegateTimers.delete(d.id);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private fireDelegate(d: DelegateTrigger): void {
|
|
581
|
+
const ev = this.currentEval;
|
|
582
|
+
const task = d.task_template
|
|
583
|
+
.replace(/\{node\}/g, os.hostname())
|
|
584
|
+
.replace(/\{state\.label\}/g, ev?.state.label ?? '')
|
|
585
|
+
.replace(/\{state\.title\}/g, ev?.state.title ?? '')
|
|
586
|
+
.replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
|
|
587
|
+
this.emit({ kind: 'delegate', id: d.id, task });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ────────────────────────────────────────────────────────────────────
|
|
591
|
+
// Dashboard commands (identical semantics to v3)
|
|
592
|
+
// ────────────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
private handleSendMessage(text: string): void {
|
|
595
|
+
if (!this.readySeenOnce) { this.pendingSends.push(text); return; }
|
|
596
|
+
this.actuallySendMessage(text);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private actuallySendMessage(text: string): void {
|
|
600
|
+
const sm = this.spec.send_message;
|
|
601
|
+
const perChar = sm.delay_ms_per_char ?? 0;
|
|
602
|
+
const beforeSubmit = resolveSubmitDelayMs(sm.delay_ms_before_submit, text);
|
|
603
|
+
if (perChar === 0) {
|
|
604
|
+
this.adapter.send_keys(text);
|
|
605
|
+
if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
606
|
+
else this.adapter.send_keys(sm.submit_key);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
let i = 0;
|
|
610
|
+
const iv = setInterval(() => {
|
|
611
|
+
if (i >= text.length) {
|
|
612
|
+
clearInterval(iv);
|
|
613
|
+
setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.adapter.send_keys(text[i]);
|
|
617
|
+
i += 1;
|
|
618
|
+
}, perChar);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private handleClickControl(controlId: string, payload?: unknown): void {
|
|
622
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
|
|
623
|
+
if (!ctl) return;
|
|
624
|
+
if (ctl.visible_when_state && !ctl.visible_when_state.includes(this.currentStateId)) return;
|
|
625
|
+
const a = ctl.action;
|
|
626
|
+
switch (a.type) {
|
|
627
|
+
case 'send_keys': this.adapter.send_keys(a.keys); return;
|
|
628
|
+
case 'open_picker':
|
|
629
|
+
this.adapter.send_keys(a.trigger_keys);
|
|
630
|
+
this.pickerInProgress = { control_id: ctl.id, spec: ctl };
|
|
631
|
+
return;
|
|
632
|
+
case 'attach_image': {
|
|
633
|
+
const p = typeof payload === 'object' && payload && (payload as any).path;
|
|
634
|
+
if (typeof p === 'string') this.adapter.send_keys(a.keys_template.replace(/\{path\}/g, p));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private handleClickModalButton(index: number): void {
|
|
641
|
+
const m = this.currentEval?.modal;
|
|
642
|
+
if (!m) return;
|
|
643
|
+
const btn = m.buttons.find(b => b.index === index);
|
|
644
|
+
if (!btn) return;
|
|
645
|
+
this.adapter.send_keys(btn.key);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private handleAttachImage(blob: string, mime: string): void {
|
|
649
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
|
|
650
|
+
if (!ctl || ctl.action.type !== 'attach_image') return;
|
|
651
|
+
const ext = guessExt(mime);
|
|
652
|
+
const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
|
|
653
|
+
try { fs.writeFileSync(tmp, Buffer.from(blob, 'base64')); } catch { return; }
|
|
654
|
+
this.adapter.send_keys(ctl.action.keys_template.replace(/\{path\}/g, tmp));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private tryAdvancePicker(screen: string): void {
|
|
658
|
+
const picker = this.pickerInProgress;
|
|
659
|
+
if (!picker) return;
|
|
660
|
+
const action = picker.spec.action;
|
|
661
|
+
if (action.type !== 'open_picker' || !action.wait_for.regex) return;
|
|
662
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
663
|
+
const sections = resolveSections(this.spec.sections ?? {}, lines);
|
|
664
|
+
const hay = sectionText(sections, action.wait_for.section, lines.join('\n'));
|
|
665
|
+
const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
|
|
666
|
+
if (!re.test(hay)) return;
|
|
667
|
+
this.pickerInProgress = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private handleExit(exitCode: number): void {
|
|
671
|
+
this.emit({ kind: 'exit', exit_code: exitCode });
|
|
672
|
+
this.shutdown();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private pushHistory(stateId: string, label: string, meta: { reason: string; via?: string; matchedRules?: string[] }): void {
|
|
676
|
+
const now = Date.now();
|
|
677
|
+
const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
|
|
678
|
+
this.prevStateAt = now;
|
|
679
|
+
this.stateHistory.push({
|
|
680
|
+
stateId, label, at: now, durationMs,
|
|
681
|
+
reason: meta.reason,
|
|
682
|
+
...(meta.via ? { via: meta.via } : {}),
|
|
683
|
+
...(meta.matchedRules ? { matchedRules: meta.matchedRules } : {}),
|
|
684
|
+
});
|
|
685
|
+
if (this.stateHistory.length > 50) this.stateHistory.shift();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private specTag(): string {
|
|
689
|
+
return this.opts.specPath.split('/').slice(-3).join('/');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private emit(ev: DashboardEvent): void {
|
|
693
|
+
for (const l of this.listeners) {
|
|
694
|
+
try { l(ev); } catch { /* listener side */ }
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
function sameModal(a: ModalSnapshot | null, b: ModalSnapshot | null): boolean {
|
|
702
|
+
if (!a && !b) return true;
|
|
703
|
+
if (!a || !b) return false;
|
|
704
|
+
if (a.title !== b.title) return false;
|
|
705
|
+
if (a.buttons.length !== b.buttons.length) return false;
|
|
706
|
+
for (let i = 0; i < a.buttons.length; i += 1) {
|
|
707
|
+
if (a.buttons[i].label !== b.buttons[i].label) return false;
|
|
708
|
+
}
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function sameControls(a: VisibleControl[], b: VisibleControl[]): boolean {
|
|
713
|
+
if (a.length !== b.length) return false;
|
|
714
|
+
for (let i = 0; i < a.length; i += 1) if (a[i].id !== b[i].id) return false;
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** A compact one-line-per-condition summary of why a transition fired. */
|
|
719
|
+
function summarizeTransition(t: TransitionEval): string[] {
|
|
720
|
+
const out: string[] = [`${t.label} fired`];
|
|
721
|
+
if (t.cond) flattenCond(t.cond, out, 1);
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function flattenCond(c: import('./fsm-evaluator.js').CondResult, out: string[], depth: number): void {
|
|
726
|
+
out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}`);
|
|
727
|
+
for (const child of c.children ?? []) flattenCond(child, out, depth + 1);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function findStable(c: import('./fsm-evaluator.js').CondResult): { totalMs: number } | null {
|
|
731
|
+
if (c.kind === 'stable') {
|
|
732
|
+
const m = /\/ (\d+)ms/.exec(c.detail);
|
|
733
|
+
return { totalMs: m ? Number(m[1]) : 0 };
|
|
734
|
+
}
|
|
735
|
+
for (const child of c.children ?? []) {
|
|
736
|
+
const r = findStable(child);
|
|
737
|
+
if (r) return r;
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function collectStableSizes(when: FsmTransition['when'], sizes: Set<number>): void {
|
|
743
|
+
if (!when) return;
|
|
744
|
+
const w = when as any;
|
|
745
|
+
if ('stable_ms' in w) { sizes.add(w.cursor_above && w.cursor_above > 0 ? w.cursor_above : -1); return; }
|
|
746
|
+
if ('all' in w) { for (const c of w.all) collectStableSizes(c, sizes); return; }
|
|
747
|
+
if ('any' in w) { for (const c of w.any) collectStableSizes(c, sizes); return; }
|
|
748
|
+
if ('not' in w) { collectStableSizes(w.not, sizes); return; }
|
|
749
|
+
}
|