@adhdev/daemon-core 0.9.82-rc.16 → 0.9.82-rc.161
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/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 +19 -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 +104 -63
- package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +27 -0
- package/dist/commands/handler.d.ts +66 -0
- package/dist/commands/mesh-coordinator.d.ts +72 -1
- package/dist/commands/router.d.ts +22 -0
- package/dist/config/chat-history.d.ts +7 -0
- package/dist/config/config.d.ts +5 -0
- package/dist/config/mesh-config.d.ts +71 -1
- package/dist/git/git-commands.d.ts +5 -1
- package/dist/index.d.ts +31 -6
- package/dist/index.js +17210 -3575
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +17127 -3551
- 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/beads-db.d.ts +72 -0
- package/dist/mesh/contracts.d.ts +164 -0
- package/dist/mesh/coordinator-prompt.d.ts +30 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +90 -0
- package/dist/mesh/mesh-events.d.ts +77 -5
- package/dist/mesh/mesh-fast-forward.d.ts +39 -0
- package/dist/mesh/mesh-host-ownership.d.ts +9 -0
- package/dist/mesh/mesh-ledger.d.ts +58 -1
- package/dist/mesh/mesh-refine-status.d.ts +26 -0
- package/dist/mesh/mesh-work-queue.d.ts +44 -5
- package/dist/mesh/preview-freshness.d.ts +18 -0
- package/dist/mesh/refine-config.d.ts +193 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
- package/dist/providers/approval-utils.d.ts +9 -0
- package/dist/providers/cli-provider-instance.d.ts +7 -2
- package/dist/providers/contracts.d.ts +98 -5
- 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 +24 -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-loader.d.ts +19 -1
- 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 +64 -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 +169 -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 +56 -0
- package/dist/providers/spec/cli-adapter.d.ts +76 -0
- package/dist/providers/spec/driver.d.ts +148 -0
- package/dist/providers/spec/evaluator.d.ts +47 -0
- package/dist/providers/spec/loader.d.ts +14 -0
- package/dist/providers/spec/native-history-executor.d.ts +39 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/schema.gen.d.ts +507 -0
- package/dist/providers/spec/types.d.ts +211 -0
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/repo-mesh-types.d.ts +100 -1
- package/dist/sessions/registry.d.ts +3 -0
- package/dist/shared-types.d.ts +12 -0
- 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 +7 -2
- package/src/boot/daemon-lifecycle.ts +17 -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 +14 -1
- package/src/cli-adapter-types.d.ts +1 -0
- package/src/cli-adapter-types.ts +18 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1086 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +664 -1137
- 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 +57 -11
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
- package/src/commands/chat-commands.ts +1597 -60
- package/src/commands/cli-manager.ts +195 -3
- package/src/commands/handler.ts +547 -2
- package/src/commands/mesh-coordinator.ts +231 -127
- package/src/commands/router.ts +3547 -502
- package/src/config/chat-history.ts +87 -24
- package/src/config/config.ts +12 -0
- package/src/config/mesh-config.ts +264 -2
- package/src/config/recent-activity.ts +8 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/detection/ide-detector.ts +26 -16
- package/src/git/git-commands.ts +17 -5
- package/src/git/git-worktree.ts +8 -1
- package/src/index.ts +91 -5
- 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/beads-db.ts +479 -0
- package/src/mesh/contracts.ts +329 -0
- package/src/mesh/coordinator-prompt.ts +204 -30
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +437 -0
- package/src/mesh/mesh-events.ts +820 -61
- package/src/mesh/mesh-fast-forward.ts +430 -0
- package/src/mesh/mesh-host-ownership.ts +73 -0
- package/src/mesh/mesh-ledger.ts +457 -104
- package/src/mesh/mesh-refine-status.ts +144 -0
- package/src/mesh/mesh-work-queue.ts +216 -158
- package/src/mesh/preview-freshness.ts +118 -0
- package/src/mesh/refine-config.ts +366 -0
- package/src/mesh/worktree-bootstrap-config.ts +247 -0
- package/src/providers/approval-utils.ts +39 -5
- package/src/providers/chat-message-normalization.ts +4 -11
- package/src/providers/cli-provider-instance.ts +388 -47
- package/src/providers/contracts.ts +105 -5
- package/src/providers/ide-provider-instance.ts +17 -3
- 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 +419 -0
- package/src/providers/native-history/dispatcher.ts +227 -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-loader.ts +420 -31
- package/src/providers/provider-schema.ts +31 -13
- 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 +245 -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 +457 -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 +119 -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 +210 -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 +153 -0
- package/src/providers/spec/cli-adapter.ts +318 -0
- package/src/providers/spec/driver.ts +498 -0
- package/src/providers/spec/evaluator.ts +268 -0
- package/src/providers/spec/loader.ts +130 -0
- package/src/providers/spec/native-history-executor.ts +612 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/schema.gen.ts +507 -0
- package/src/providers/spec/schema.json +210 -0
- package/src/providers/spec/types.ts +230 -0
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/version-archive.ts +38 -20
- package/src/repo-mesh-types.ts +110 -1
- package/src/sessions/registry.ts +3 -0
- package/src/shared-types.ts +9 -0
- package/src/status/builders.ts +23 -6
- package/src/status/reporter.ts +15 -0
- package/src/status/snapshot.ts +35 -11
- package/src/system/host-memory.ts +29 -12
- package/src/types.ts +5 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecDriver — the single fixed script that turns a CliSpec + a
|
|
3
|
+
* TerminalAdapter into the dashboard-facing wire protocol.
|
|
4
|
+
*
|
|
5
|
+
* Wire (dashboard ↔ daemon):
|
|
6
|
+
*
|
|
7
|
+
* dashboard → daemon:
|
|
8
|
+
* { kind: 'send_message', text }
|
|
9
|
+
* { kind: 'click_control', control_id, payload? }
|
|
10
|
+
* { kind: 'click_modal_button', index }
|
|
11
|
+
* { kind: 'attach_image', blob, mime }
|
|
12
|
+
* { kind: 'resize', cols, rows }
|
|
13
|
+
* { kind: 'cancel' }
|
|
14
|
+
* { kind: 'shutdown' }
|
|
15
|
+
*
|
|
16
|
+
* daemon → dashboard:
|
|
17
|
+
* { kind: 'pty_data', chunk }
|
|
18
|
+
* { kind: 'state_changed',
|
|
19
|
+
* state: { id, label, title },
|
|
20
|
+
* modal: null | { title, buttons: [{ index, label }] },
|
|
21
|
+
* controls: [{ id, label, action_type }] }
|
|
22
|
+
* { kind: 'notification', id, title, body }
|
|
23
|
+
* { kind: 'delegate', id, task }
|
|
24
|
+
* { kind: 'spec_trace', entries: [...] }
|
|
25
|
+
* { kind: 'exit', exit_code }
|
|
26
|
+
*
|
|
27
|
+
* Hot reload: spec.json is watched. A reload re-evaluates and emits a
|
|
28
|
+
* fresh state_changed; no PTY restart is required.
|
|
29
|
+
*
|
|
30
|
+
* Delegate timers: after_duration_ms is enforced here (the evaluator
|
|
31
|
+
* is stateless). A timer is armed when a delegate's when_state is
|
|
32
|
+
* entered and cancelled when it changes.
|
|
33
|
+
*/
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
import * as fs from 'node:fs';
|
|
37
|
+
import * as os from 'node:os';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
|
|
40
|
+
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
41
|
+
import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
|
|
42
|
+
import { loadSpec } from './loader.js';
|
|
43
|
+
import type { CliSpec, Control, DelegateTrigger } from './types.js';
|
|
44
|
+
|
|
45
|
+
export type DashboardEvent =
|
|
46
|
+
| { kind: 'pty_data'; chunk: string }
|
|
47
|
+
| { kind: 'state_changed'; state: { id: string; label: string; title: string | null };
|
|
48
|
+
modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
|
|
49
|
+
controls: { id: string; label: string; action_type: string }[] }
|
|
50
|
+
| { kind: 'notification'; id: string; title: string; body: string }
|
|
51
|
+
| { kind: 'delegate'; id: string; task: string }
|
|
52
|
+
| { kind: 'spec_trace'; entries: TraceEntry[] }
|
|
53
|
+
| { kind: 'exit'; exit_code: number }
|
|
54
|
+
| { kind: 'spec_error'; errors: string[] };
|
|
55
|
+
|
|
56
|
+
export type DashboardCommand =
|
|
57
|
+
| { kind: 'send_message'; text: string }
|
|
58
|
+
| { kind: 'pty_write'; data: string }
|
|
59
|
+
| { kind: 'click_control'; control_id: string; payload?: unknown }
|
|
60
|
+
| { kind: 'click_modal_button'; index: number }
|
|
61
|
+
| { kind: 'attach_image'; blob: string; mime: string }
|
|
62
|
+
| { kind: 'resize'; cols: number; rows: number }
|
|
63
|
+
| { kind: 'cancel' }
|
|
64
|
+
| { kind: 'shutdown' };
|
|
65
|
+
|
|
66
|
+
export interface SpecDriverOpts {
|
|
67
|
+
specPath: string;
|
|
68
|
+
workingDir: string;
|
|
69
|
+
extraEnv?: Record<string, string>;
|
|
70
|
+
cols?: number;
|
|
71
|
+
rows?: number;
|
|
72
|
+
/** Set false to skip the spec.json fs.watch. */
|
|
73
|
+
hotReload?: boolean;
|
|
74
|
+
/** Set true to forward trace entries on every state_changed. */
|
|
75
|
+
emitTrace?: boolean;
|
|
76
|
+
/** Inject the daemon's PTY transport (typically SessionHostPtyTransportFactory). */
|
|
77
|
+
transportFactory?: PtyTransportFactory;
|
|
78
|
+
/**
|
|
79
|
+
* Extra CLI args appended to spec.spawn_args. Used by the daemon to
|
|
80
|
+
* pass per-launch arguments like `--session-id <uuid>` so the agent
|
|
81
|
+
* uses the daemon's providerSessionId instead of generating its own.
|
|
82
|
+
*/
|
|
83
|
+
extraCliArgs?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How long after start() to ignore "idle" readings before treating one
|
|
87
|
+
* as a real prompt-ready signal. Most TUIs paint a banner + tool list +
|
|
88
|
+
* status line on startup that can briefly score as "no state matches →
|
|
89
|
+
* default idle"; firing a send_keys into that buffer gets the input
|
|
90
|
+
* wiped when the banner clears. 2s covers the slow agents (hermes,
|
|
91
|
+
* agy) without delaying snappy ones (claude/codex) by much. */
|
|
92
|
+
const STARTUP_GRACE_MS = 2500;
|
|
93
|
+
|
|
94
|
+
/** Min time to stay in busy after the evaluator last reports it. Most TUIs
|
|
95
|
+
* flicker their busy spinner on and off as layout reflows (claude streams
|
|
96
|
+
* body text in the same region where the spinner lives, so the spinner
|
|
97
|
+
* disappears mid-response and the footer-only frames score as idle). The
|
|
98
|
+
* hold turns short idle gaps inside a single turn into "still busy", at
|
|
99
|
+
* the cost of looking busy for a couple seconds after a turn actually
|
|
100
|
+
* ends. Pick something long enough to cover the longest claude streaming
|
|
101
|
+
* gap we've observed (~5s between spinner refreshes) without dragging
|
|
102
|
+
* the post-turn idle indication noticeably. */
|
|
103
|
+
const BUSY_HOLD_MS = 6000;
|
|
104
|
+
|
|
105
|
+
export class SpecDriver {
|
|
106
|
+
private spec!: CliSpec;
|
|
107
|
+
private adapter!: TerminalAdapter;
|
|
108
|
+
private listeners = new Set<(ev: DashboardEvent) => void>();
|
|
109
|
+
private currentStateId: string | null = null;
|
|
110
|
+
/** Have we ever seen the spec's idle state *after* the startup grace
|
|
111
|
+
* window? Until we do, the agent's startup banner may still be
|
|
112
|
+
* painting and any send_message we forward to the PTY will be wiped
|
|
113
|
+
* when the banner clears the screen. Queue + drain on first valid
|
|
114
|
+
* idle. */
|
|
115
|
+
private idleSeenOnce = false;
|
|
116
|
+
private startedAtMs = 0;
|
|
117
|
+
private pendingSends: string[] = [];
|
|
118
|
+
private currentEval: SpecEvaluation | null = null;
|
|
119
|
+
private pickerInProgress: { control_id: string; phase: 'waiting'; spec: Control } | null = null;
|
|
120
|
+
private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
121
|
+
/** Timestamp of the last time the evaluator returned busy. Used to debounce
|
|
122
|
+
* the busy → idle transition (see reevaluate). */
|
|
123
|
+
private lastBusyAt = 0;
|
|
124
|
+
/** The exact busy state object we last saw — held alongside lastBusyAt so
|
|
125
|
+
* the hold can re-emit the same { id: 'busy', label, title } payload the
|
|
126
|
+
* dashboard already learned about. currentEval can't fill this role
|
|
127
|
+
* because the evaluator already moved past busy by the time the hold
|
|
128
|
+
* kicks in. */
|
|
129
|
+
private lastBusyState: SpecEvaluation['state'] | null = null;
|
|
130
|
+
/** Timer that re-runs evaluate() once the hold window expires. Needed
|
|
131
|
+
* because the PTY stops emitting once the agent finishes; without an
|
|
132
|
+
* explicit wake-up there's nothing to trigger the busy → idle
|
|
133
|
+
* downshift. */
|
|
134
|
+
private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
135
|
+
private specWatcher: fs.FSWatcher | null = null;
|
|
136
|
+
|
|
137
|
+
constructor(private readonly opts: SpecDriverOpts) {
|
|
138
|
+
this.loadSpecOrThrow();
|
|
139
|
+
this.adapter = new TerminalAdapter(
|
|
140
|
+
this.buildAdapterOpts(),
|
|
141
|
+
{
|
|
142
|
+
init: () => this.emitInitialState(),
|
|
143
|
+
on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
|
|
144
|
+
on_screen_changed: () => this.reevaluate(),
|
|
145
|
+
on_exit: ({ exitCode }) => this.handleExit(exitCode),
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
if (this.opts.hotReload !== false) this.armSpecWatcher();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Subscribe to outbound events. Returns an unsubscribe fn. */
|
|
152
|
+
subscribe(listener: (ev: DashboardEvent) => void): () => void {
|
|
153
|
+
this.listeners.add(listener);
|
|
154
|
+
return () => { this.listeners.delete(listener); };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
start(): void {
|
|
158
|
+
this.startedAtMs = Date.now();
|
|
159
|
+
this.adapter.start();
|
|
160
|
+
// Make sure we always re-evaluate once the startup grace has
|
|
161
|
+
// expired. Without an explicit wake-up the PTY can go quiet
|
|
162
|
+
// immediately after the banner paints (agy: the screen stays at
|
|
163
|
+
// the input prompt forever), there's no on_screen_changed event
|
|
164
|
+
// to trigger reevaluate, and any send_message we queued during
|
|
165
|
+
// the grace window would never drain.
|
|
166
|
+
const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
|
|
167
|
+
setTimeout(() => this.reevaluate(), graceMs + 100);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dispatch(cmd: DashboardCommand): void {
|
|
171
|
+
switch (cmd.kind) {
|
|
172
|
+
case 'send_message': this.handleSendMessage(cmd.text); return;
|
|
173
|
+
case 'pty_write': this.adapter.send_keys(cmd.data); return;
|
|
174
|
+
case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
|
|
175
|
+
case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
|
|
176
|
+
case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
|
|
177
|
+
case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
|
|
178
|
+
case 'cancel': this.adapter.send_keys('\x03'); return;
|
|
179
|
+
case 'shutdown': this.shutdown(); return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
shutdown(): void {
|
|
184
|
+
for (const t of this.delegateTimers.values()) clearTimeout(t);
|
|
185
|
+
this.delegateTimers.clear();
|
|
186
|
+
this.specWatcher?.close();
|
|
187
|
+
this.adapter.kill();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ────────────────────────────────────────────────────────────────────
|
|
191
|
+
// Loading & adapter wiring
|
|
192
|
+
// ────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
private loadSpecOrThrow(): void {
|
|
195
|
+
const res = loadSpec(this.opts.specPath);
|
|
196
|
+
if (!res.ok) throw new Error(`spec invalid: ${res.errors.join('; ')}`);
|
|
197
|
+
this.spec = res.spec;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private buildAdapterOpts(): TerminalAdapterOpts {
|
|
201
|
+
const baseArgs = this.spec.spawn_args ?? [];
|
|
202
|
+
const extra = this.opts.extraCliArgs ?? [];
|
|
203
|
+
return {
|
|
204
|
+
binary: this.spec.binary,
|
|
205
|
+
args: [...baseArgs, ...extra],
|
|
206
|
+
cwd: this.opts.workingDir,
|
|
207
|
+
env: { ...(this.spec.env ?? {}), ...(this.opts.extraEnv ?? {}) },
|
|
208
|
+
cols: this.opts.cols ?? 100,
|
|
209
|
+
rows: this.opts.rows ?? 30,
|
|
210
|
+
transportFactory: this.opts.transportFactory,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private armSpecWatcher(): void {
|
|
215
|
+
try {
|
|
216
|
+
this.specWatcher = fs.watch(this.opts.specPath, { persistent: false }, () => {
|
|
217
|
+
// Re-read; if it parses, replace and re-evaluate. Errors are
|
|
218
|
+
// surfaced to the dashboard so the spec author sees them live.
|
|
219
|
+
const res = loadSpec(this.opts.specPath);
|
|
220
|
+
if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
|
|
221
|
+
this.spec = res.spec;
|
|
222
|
+
this.reevaluate(/*forceEmit*/ true);
|
|
223
|
+
});
|
|
224
|
+
} catch { /* watch is best-effort */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Evaluation pipeline
|
|
229
|
+
// ────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
private emitInitialState(): void {
|
|
232
|
+
// Start with an empty-screen evaluation so the dashboard immediately
|
|
233
|
+
// knows about the default state.
|
|
234
|
+
this.reevaluate(true);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Re-arm the timer that wakes the driver up after BUSY_HOLD_MS so it
|
|
238
|
+
* can decide whether to downshift to idle. Always uses the most recent
|
|
239
|
+
* hold value so a spec hot-reload that shortens the hold takes effect
|
|
240
|
+
* on the next busy entry. Safe to call repeatedly; only the last call
|
|
241
|
+
* fires. */
|
|
242
|
+
private scheduleBusyExpiry(holdMs: number): void {
|
|
243
|
+
if (this.busyExpiryTimer) clearTimeout(this.busyExpiryTimer);
|
|
244
|
+
this.busyExpiryTimer = setTimeout(() => {
|
|
245
|
+
this.busyExpiryTimer = null;
|
|
246
|
+
this.reevaluate();
|
|
247
|
+
}, Math.max(holdMs + 50, 100));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private reevaluate(forceEmit = false): void {
|
|
251
|
+
const screen = this.adapter.snapshot();
|
|
252
|
+
const ev = evaluate(this.spec, screen);
|
|
253
|
+
|
|
254
|
+
// Busy hold: many TUIs flicker between busy and idle every frame
|
|
255
|
+
// (claude in particular — its token counter appears and disappears
|
|
256
|
+
// as the layout reflows). Once we see busy, hold that state for
|
|
257
|
+
// BUSY_HOLD_MS before allowing a downshift to idle. Any state
|
|
258
|
+
// *other* than idle clears the hold immediately (approval is
|
|
259
|
+
// strictly more interesting than busy, so it shouldn't be held
|
|
260
|
+
// back). The hold lives on the driver, not the evaluator, so the
|
|
261
|
+
// spec author doesn't have to think about debouncing.
|
|
262
|
+
let evState = ev.state;
|
|
263
|
+
const busyHoldMs = this.spec.debounce?.busy_hold_ms ?? BUSY_HOLD_MS;
|
|
264
|
+
if (this.currentStateId === 'busy' && evState.id === 'idle') {
|
|
265
|
+
const ageMs = Date.now() - this.lastBusyAt;
|
|
266
|
+
if (ageMs < busyHoldMs) {
|
|
267
|
+
// Pin to the last seen busy state directly — currentEval can
|
|
268
|
+
// already be idle at this point (it tracks the previous tick,
|
|
269
|
+
// which during the flicker is just as likely to be idle as
|
|
270
|
+
// busy), so we can't rely on it to recover the busy label.
|
|
271
|
+
evState = this.lastBusyState ?? evState;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (evState.id === 'busy') {
|
|
275
|
+
this.lastBusyAt = Date.now();
|
|
276
|
+
this.lastBusyState = evState;
|
|
277
|
+
// Schedule a re-evaluation when the hold window expires. PTYs
|
|
278
|
+
// typically stop emitting once the agent stops printing (the
|
|
279
|
+
// footer settles), so without an explicit timer the driver
|
|
280
|
+
// never wakes up to downshift to idle and the dashboard sees
|
|
281
|
+
// status stuck at generating long after the turn ended.
|
|
282
|
+
this.scheduleBusyExpiry(busyHoldMs);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const changed = forceEmit
|
|
286
|
+
|| evState.id !== this.currentStateId
|
|
287
|
+
|| !shallowSameModal(ev, this.currentEval)
|
|
288
|
+
|| !shallowSameControls(ev, this.currentEval);
|
|
289
|
+
|
|
290
|
+
// Picker state tracking — if a picker is waiting for its
|
|
291
|
+
// wait_for cue and now sees it, complete the picker flow.
|
|
292
|
+
if (this.pickerInProgress) this.tryAdvancePicker(screen);
|
|
293
|
+
|
|
294
|
+
this.currentEval = ev;
|
|
295
|
+
// First time we ever see idle (or any non-busy non-startup state),
|
|
296
|
+
// flush any queued send_message calls that arrived before the
|
|
297
|
+
// agent finished painting its banner. Subsequent idle/busy
|
|
298
|
+
// toggles don't retrigger.
|
|
299
|
+
// Wait at least the spec's startup_grace_ms after start() so we
|
|
300
|
+
// don't treat a transient "matches no state, default idle"
|
|
301
|
+
// reading during the banner paint as a real idle. After that,
|
|
302
|
+
// the first non-busy observation is a real prompt-ready signal
|
|
303
|
+
// and we drain any queued send_message calls.
|
|
304
|
+
const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
|
|
305
|
+
const sinceStart = Date.now() - this.startedAtMs;
|
|
306
|
+
if (!this.idleSeenOnce && evState.id !== 'busy' && sinceStart >= graceMs) {
|
|
307
|
+
this.idleSeenOnce = true;
|
|
308
|
+
const queued = this.pendingSends.splice(0);
|
|
309
|
+
for (const text of queued) {
|
|
310
|
+
setTimeout(() => this.actuallySendMessage(text), 50);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (changed) {
|
|
314
|
+
this.currentStateId = evState.id;
|
|
315
|
+
this.emit({
|
|
316
|
+
kind: 'state_changed',
|
|
317
|
+
state: evState,
|
|
318
|
+
modal: ev.modal ? { title: ev.modal.title, buttons: ev.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
|
|
319
|
+
controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
|
|
320
|
+
});
|
|
321
|
+
for (const n of ev.notifications) this.emit({ kind: 'notification', id: n.id, title: n.title, body: n.body });
|
|
322
|
+
this.armOrCancelDelegateTimers(evState.id);
|
|
323
|
+
if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private armOrCancelDelegateTimers(currentStateId: string): void {
|
|
328
|
+
for (const d of this.spec.delegate ?? []) {
|
|
329
|
+
const armed = this.delegateTimers.has(d.id);
|
|
330
|
+
const shouldFire = d.when_state === currentStateId;
|
|
331
|
+
if (shouldFire && !armed) {
|
|
332
|
+
const delay = d.after_duration_ms ?? 0;
|
|
333
|
+
const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
|
|
334
|
+
this.delegateTimers.set(d.id, t);
|
|
335
|
+
} else if (!shouldFire && armed) {
|
|
336
|
+
clearTimeout(this.delegateTimers.get(d.id)!);
|
|
337
|
+
this.delegateTimers.delete(d.id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private fireDelegate(d: DelegateTrigger): void {
|
|
343
|
+
const ev = this.currentEval;
|
|
344
|
+
const task = (d.task_template)
|
|
345
|
+
.replace(/\{node\}/g, os.hostname())
|
|
346
|
+
.replace(/\{state\.label\}/g, ev?.state.label ?? '')
|
|
347
|
+
.replace(/\{state\.title\}/g, ev?.state.title ?? '')
|
|
348
|
+
.replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
|
|
349
|
+
this.emit({ kind: 'delegate', id: d.id, task });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ────────────────────────────────────────────────────────────────────
|
|
353
|
+
// Dashboard commands
|
|
354
|
+
// ────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
private handleSendMessage(text: string): void {
|
|
357
|
+
if (!this.idleSeenOnce) {
|
|
358
|
+
// Agent is still drawing its startup banner. Queue and drain
|
|
359
|
+
// when we see idle so we don't fire keystrokes into a buffer
|
|
360
|
+
// that's about to be cleared.
|
|
361
|
+
this.pendingSends.push(text);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.actuallySendMessage(text);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private actuallySendMessage(text: string): void {
|
|
368
|
+
const sm = this.spec.send_message;
|
|
369
|
+
const perChar = sm.delay_ms_per_char ?? 0;
|
|
370
|
+
const beforeSubmit = sm.delay_ms_before_submit ?? 0;
|
|
371
|
+
if (perChar === 0) {
|
|
372
|
+
this.adapter.send_keys(text);
|
|
373
|
+
if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
374
|
+
else this.adapter.send_keys(sm.submit_key);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
let i = 0;
|
|
378
|
+
const iv = setInterval(() => {
|
|
379
|
+
if (i >= text.length) {
|
|
380
|
+
clearInterval(iv);
|
|
381
|
+
setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.adapter.send_keys(text[i]);
|
|
385
|
+
i += 1;
|
|
386
|
+
}, perChar);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private handleClickControl(controlId: string, payload?: unknown): void {
|
|
390
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
|
|
391
|
+
if (!ctl) return;
|
|
392
|
+
// Reject controls that aren't currently visible.
|
|
393
|
+
const ev = this.currentEval;
|
|
394
|
+
if (ctl.visible_when_state && ev && !ctl.visible_when_state.includes(ev.state.id)) return;
|
|
395
|
+
|
|
396
|
+
const a = ctl.action;
|
|
397
|
+
switch (a.type) {
|
|
398
|
+
case 'send_keys':
|
|
399
|
+
this.adapter.send_keys(a.keys);
|
|
400
|
+
return;
|
|
401
|
+
case 'open_picker':
|
|
402
|
+
this.adapter.send_keys(a.trigger_keys);
|
|
403
|
+
this.pickerInProgress = { control_id: ctl.id, phase: 'waiting', spec: ctl };
|
|
404
|
+
return;
|
|
405
|
+
case 'attach_image': {
|
|
406
|
+
const p = typeof payload === 'object' && payload && (payload as any).path;
|
|
407
|
+
if (typeof p === 'string') {
|
|
408
|
+
const keys = a.keys_template.replace(/\{path\}/g, p);
|
|
409
|
+
this.adapter.send_keys(keys);
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private handleClickModalButton(index: number): void {
|
|
417
|
+
const m = this.currentEval?.modal;
|
|
418
|
+
if (!m) return;
|
|
419
|
+
const btn = m.buttons.find(b => b.index === index);
|
|
420
|
+
if (!btn) return;
|
|
421
|
+
this.adapter.send_keys(btn.key);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private handleAttachImage(blob: string, mime: string): void {
|
|
425
|
+
// tempfile_then_keys: write decoded bytes to a temp file, then
|
|
426
|
+
// fire the attach_image control_bar entry with that path.
|
|
427
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
|
|
428
|
+
if (!ctl || ctl.action.type !== 'attach_image') return;
|
|
429
|
+
const ext = guessExt(mime);
|
|
430
|
+
const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
|
|
431
|
+
try {
|
|
432
|
+
fs.writeFileSync(tmp, Buffer.from(blob, 'base64'));
|
|
433
|
+
} catch { return; }
|
|
434
|
+
const keys = ctl.action.keys_template.replace(/\{path\}/g, tmp);
|
|
435
|
+
this.adapter.send_keys(keys);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private tryAdvancePicker(screen: string): void {
|
|
439
|
+
const picker = this.pickerInProgress;
|
|
440
|
+
if (!picker) return;
|
|
441
|
+
const action = picker.spec.action;
|
|
442
|
+
if (action.type !== 'open_picker') return;
|
|
443
|
+
const hay = sectionTextFromSnapshot(this.spec, screen, action.wait_for.section) ?? screen;
|
|
444
|
+
const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
|
|
445
|
+
if (!re.test(hay)) return;
|
|
446
|
+
// Cue arrived — the dashboard now sees the picker modal via
|
|
447
|
+
// state_changed. We do NOT auto-submit; the dashboard must call
|
|
448
|
+
// click_modal_button(index). After the modal closes the picker
|
|
449
|
+
// tracking naturally clears the next time the screen settles.
|
|
450
|
+
this.pickerInProgress = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private handleExit(exitCode: number): void {
|
|
454
|
+
this.emit({ kind: 'exit', exit_code: exitCode });
|
|
455
|
+
this.shutdown();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private emit(ev: DashboardEvent): void {
|
|
459
|
+
for (const l of this.listeners) {
|
|
460
|
+
try { l(ev); } catch { /* listener side */ }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function shallowSameModal(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
|
|
466
|
+
if (!b) return false;
|
|
467
|
+
if (!a.modal && !b.modal) return true;
|
|
468
|
+
if (!a.modal || !b.modal) return false;
|
|
469
|
+
if (a.modal.title !== b.modal.title) return false;
|
|
470
|
+
if (a.modal.buttons.length !== b.modal.buttons.length) return false;
|
|
471
|
+
for (let i = 0; i < a.modal.buttons.length; i += 1) {
|
|
472
|
+
if (a.modal.buttons[i].label !== b.modal.buttons[i].label) return false;
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function shallowSameControls(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
|
|
478
|
+
if (!b) return false;
|
|
479
|
+
if (a.controls.length !== b.controls.length) return false;
|
|
480
|
+
for (let i = 0; i < a.controls.length; i += 1) {
|
|
481
|
+
if (a.controls[i].id !== b.controls[i].id) return false;
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sectionTextFromSnapshot(spec: CliSpec, screen: string, sectionId: string | undefined): string | null {
|
|
487
|
+
if (!sectionId) return null;
|
|
488
|
+
const ev = evaluate(spec, screen);
|
|
489
|
+
return ev.sections.find(s => s.id === sectionId)?.text ?? null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function guessExt(mime: string): string {
|
|
493
|
+
if (/png/i.test(mime)) return '.png';
|
|
494
|
+
if (/jpe?g/i.test(mime)) return '.jpg';
|
|
495
|
+
if (/gif/i.test(mime)) return '.gif';
|
|
496
|
+
if (/webp/i.test(mime)) return '.webp';
|
|
497
|
+
return '.bin';
|
|
498
|
+
}
|