@adhdev/daemon-core 0.9.82-rc.288 → 0.9.82-rc.289

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.
@@ -58,6 +58,20 @@ export interface FsmTransition {
58
58
  /** Human label for the debugger. */
59
59
  label?: string;
60
60
  }
61
+ /**
62
+ * Declarative "trust this folder before spawn" config. Some agent CLIs gate the
63
+ * first run in a new folder behind an interactive trust prompt and persist the
64
+ * answer as a string array in a JSON settings file. Declaring this lets the
65
+ * engine add the workspace path to that array before spawn so the prompt never
66
+ * appears — the robust alternative to detecting and auto-clicking the modal.
67
+ * CLIs without such a gate omit this field and the engine does nothing.
68
+ */
69
+ export interface PreLaunchTrust {
70
+ /** Path to the CLI's JSON settings file. A leading `~` expands to $HOME. */
71
+ settings_path: string;
72
+ /** Key of the string-array of trusted folder paths within that file. */
73
+ key: string;
74
+ }
61
75
  export interface CliSpecV4 {
62
76
  $schema: 'adhdev:cli/spec@4';
63
77
  id: string;
@@ -81,6 +95,14 @@ export interface CliSpecV4 {
81
95
  send_on_spawn?: string[];
82
96
  /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
83
97
  send_on_spawn_delay_ms?: number;
98
+ /**
99
+ * Optional pre-spawn folder-trust step. When present, the engine adds the
100
+ * launch workspace path to the declared trusted-folders array before
101
+ * spawning, so a CLI that gates first run on a "trust this folder?" prompt
102
+ * (e.g. antigravity's `agy`) starts trusted and never blocks. Omitted for
103
+ * CLIs without such a gate. See pre-launch-trust.ts.
104
+ */
105
+ pre_launch_trust?: PreLaunchTrust;
84
106
  send_message: {
85
107
  submit_key: string;
86
108
  delay_ms_before_submit?: number;
@@ -0,0 +1,16 @@
1
+ import type { PreLaunchTrust } from './fsm-types.js';
2
+ /**
3
+ * Idempotently add `workingDir` (realpath) to the trusted-folders array named
4
+ * by `trust.key` inside the JSON settings file at `trust.settings_path`.
5
+ *
6
+ * - Creates the file (and parent dir) if missing.
7
+ * - Preserves all other settings; only the trust array is touched.
8
+ * - No-ops if the path is already present.
9
+ * - Best-effort: any failure is logged and swallowed. A failed pre-trust must
10
+ * not block the launch — the worst case is the old behavior (the FSM still
11
+ * detects the trust modal as an approval state), not a crash.
12
+ *
13
+ * Returns the path that was added (realpath), or null if nothing changed /
14
+ * an error occurred — purely so callers/tests can assert the effect.
15
+ */
16
+ export declare function applyPreLaunchTrust(trust: PreLaunchTrust, workingDir: string): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.288",
3
+ "version": "0.9.82-rc.289",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.288",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.289",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -6965,6 +6965,32 @@ export class DaemonCommandRouter {
6965
6965
  const mesh = meshRecord?.mesh;
6966
6966
  const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
6967
6967
 
6968
+ // Guard: refuse to remove the coordinator's OWN local base node
6969
+ // (same machine, NOT a worktree). Removing it breaks live mesh
6970
+ // membership — the coordinator can no longer be reached and has
6971
+ // to be restarted. Worktree clones are always safe to remove;
6972
+ // only the non-worktree node bound to this daemon is protected.
6973
+ // An explicit force:true overrides for intentional mesh teardown.
6974
+ if (node && !args?._meshDirectDispatch && node.isLocalWorktree !== true && args?.force !== true) {
6975
+ const nodeDaemonId = typeof node.daemonId === 'string' ? node.daemonId.trim() : '';
6976
+ const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>) || '';
6977
+ const selfDaemonId = this.deps.statusInstanceId || '';
6978
+ const selfMachineId = (() => { try { return loadConfig().machineId || ''; } catch { return ''; } })();
6979
+ const isCoordinatorBaseNode =
6980
+ (!!selfDaemonId && (nodeDaemonId === selfDaemonId || nodeMachineId === selfDaemonId))
6981
+ || (!!selfMachineId && (nodeDaemonId === selfMachineId || nodeMachineId === selfMachineId));
6982
+ if (isCoordinatorBaseNode) {
6983
+ return {
6984
+ success: false,
6985
+ removed: false,
6986
+ code: 'mesh_remove_coordinator_base_node_protected',
6987
+ error: `Refusing to remove the coordinator's own base node '${typeof node.workspace === 'string' ? node.workspace : nodeId}'. `
6988
+ + `It is the local non-worktree node bound to this coordinator daemon; removing it breaks live mesh membership and forces a restart.`,
6989
+ recoveryHint: 'Remove worktree clone nodes instead, or pass force:true only if you are intentionally tearing down this mesh and accept that the coordinator must be re-registered/restarted.',
6990
+ };
6991
+ }
6992
+ }
6993
+
6968
6994
  const sessionCleanupMode = this.normalizeMeshSessionCleanupMode(
6969
6995
  args?.sessionCleanupMode ?? args?.session_cleanup_mode ?? mesh?.policy?.sessionCleanupOnNodeRemove,
6970
6996
  );
@@ -34,6 +34,7 @@ import {
34
34
  initialState, stateById, statusForState, outgoingTransitions,
35
35
  } from './fsm-types.js';
36
36
  import { loadFsmSpec } from './fsm-loader.js';
37
+ import { applyPreLaunchTrust } from './pre-launch-trust.js';
37
38
  import type { Control, DelegateTrigger } from './types.js';
38
39
  import { LOG } from '../../logging/logger.js';
39
40
 
@@ -245,6 +246,12 @@ export class FsmDriver implements ISpecDriver {
245
246
  this.currentStateId = init.id;
246
247
  this.stateEnteredAt = now;
247
248
  this.prevStateAt = now;
249
+ // Pre-trust the workspace before spawning so a first-run folder-trust
250
+ // prompt never appears (best-effort; failures fall back to the FSM's
251
+ // trust-modal detection). Only runs for specs that declare it.
252
+ if (this.spec.pre_launch_trust) {
253
+ applyPreLaunchTrust(this.spec.pre_launch_trust, this.opts.workingDir);
254
+ }
248
255
  this.adapter.start();
249
256
  // Prime focus-gated TUIs (see CliSpecV4.send_on_spawn). Written once,
250
257
  // shortly after spawn, so the input stream is awake before the first
@@ -36,6 +36,16 @@ export function validateFsmSpec(raw: unknown): string[] {
36
36
  if (!spec.binary) errs.push('binary is required');
37
37
  if (!spec.send_message?.submit_key) errs.push('send_message.submit_key is required');
38
38
 
39
+ if (spec.pre_launch_trust !== undefined) {
40
+ const t = spec.pre_launch_trust as { settings_path?: unknown; key?: unknown };
41
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
42
+ errs.push('pre_launch_trust must be an object');
43
+ } else {
44
+ if (typeof t.settings_path !== 'string' || !t.settings_path) errs.push('pre_launch_trust.settings_path is required');
45
+ if (typeof t.key !== 'string' || !t.key) errs.push('pre_launch_trust.key is required');
46
+ }
47
+ }
48
+
39
49
  if (!Array.isArray(spec.states) || spec.states.length === 0) {
40
50
  errs.push('states[] must be a non-empty array');
41
51
  return errs;
@@ -118,6 +118,21 @@ export interface FsmTransition {
118
118
  label?: string;
119
119
  }
120
120
 
121
+ /**
122
+ * Declarative "trust this folder before spawn" config. Some agent CLIs gate the
123
+ * first run in a new folder behind an interactive trust prompt and persist the
124
+ * answer as a string array in a JSON settings file. Declaring this lets the
125
+ * engine add the workspace path to that array before spawn so the prompt never
126
+ * appears — the robust alternative to detecting and auto-clicking the modal.
127
+ * CLIs without such a gate omit this field and the engine does nothing.
128
+ */
129
+ export interface PreLaunchTrust {
130
+ /** Path to the CLI's JSON settings file. A leading `~` expands to $HOME. */
131
+ settings_path: string;
132
+ /** Key of the string-array of trusted folder paths within that file. */
133
+ key: string;
134
+ }
135
+
121
136
  // ─────────────────────────────────────────────────────────────────────────────
122
137
  // CliSpecV4 — the v4 runtime spec
123
138
  // ─────────────────────────────────────────────────────────────────────────────
@@ -145,6 +160,14 @@ export interface CliSpecV4 {
145
160
  send_on_spawn?: string[];
146
161
  /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
147
162
  send_on_spawn_delay_ms?: number;
163
+ /**
164
+ * Optional pre-spawn folder-trust step. When present, the engine adds the
165
+ * launch workspace path to the declared trusted-folders array before
166
+ * spawning, so a CLI that gates first run on a "trust this folder?" prompt
167
+ * (e.g. antigravity's `agy`) starts trusted and never blocks. Omitted for
168
+ * CLIs without such a gate. See pre-launch-trust.ts.
169
+ */
170
+ pre_launch_trust?: PreLaunchTrust;
148
171
  send_message: {
149
172
  submit_key: string;
150
173
  delay_ms_before_submit?: number;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * pre_launch_trust — generic, declarative "trust this folder before spawn"
3
+ * step for spec-backed CLI providers.
4
+ *
5
+ * Some agent CLIs (the canonical case is antigravity's `agy`) gate the first
6
+ * run in any new folder behind an interactive "Do you trust the files in this
7
+ * folder?" prompt. Under the v4 FSM spec path the daemon spawns the binary
8
+ * directly in the worktree (`cwd = workingDir`), so every fresh worktree —
9
+ * every delegated mesh task running in its own clone — hits that prompt and
10
+ * stalls until something clicks through it. (The legacy bash-wrapper symlink
11
+ * trick in provider.v1.json's `spawn` block is not used by SpecCliAdapter.)
12
+ *
13
+ * These CLIs persist their trusted folders in a JSON settings file as a string
14
+ * array. If we add the workspace path to that array *before* spawning, the
15
+ * prompt never appears. That is the most robust fix: the agent runs trusted
16
+ * from the first frame instead of relying on the FSM to detect and auto-click
17
+ * a modal whose wording or position could drift.
18
+ *
19
+ * The mechanism is intentionally data-driven and CLI-agnostic. A spec declares
20
+ * the settings file and the array key; the engine does the rest. CLIs that do
21
+ * not have a folder-trust gate simply omit `pre_launch_trust` and this code
22
+ * never runs for them — so other providers (claude/codex/hermes) are untouched.
23
+ */
24
+ 'use strict';
25
+
26
+ import * as fs from 'node:fs';
27
+ import * as os from 'node:os';
28
+ import * as path from 'node:path';
29
+ import type { PreLaunchTrust } from './fsm-types.js';
30
+ import { LOG } from '../../logging/logger.js';
31
+
32
+ /** Expand a leading `~` to the user's home directory. */
33
+ function expandHome(p: string): string {
34
+ if (p === '~') return os.homedir();
35
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
36
+ return p;
37
+ }
38
+
39
+ /**
40
+ * Resolve the canonical, real (symlink-followed) absolute form of the
41
+ * workspace path. Trust files store realpaths, and matching has to be exact, so
42
+ * we normalise the same way the CLI does. Falls back to the raw path if the
43
+ * directory can't be stat'd (e.g. it does not exist yet).
44
+ */
45
+ function realWorkspacePath(workingDir: string): string {
46
+ try {
47
+ return fs.realpathSync(workingDir);
48
+ } catch {
49
+ return path.resolve(workingDir);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Idempotently add `workingDir` (realpath) to the trusted-folders array named
55
+ * by `trust.key` inside the JSON settings file at `trust.settings_path`.
56
+ *
57
+ * - Creates the file (and parent dir) if missing.
58
+ * - Preserves all other settings; only the trust array is touched.
59
+ * - No-ops if the path is already present.
60
+ * - Best-effort: any failure is logged and swallowed. A failed pre-trust must
61
+ * not block the launch — the worst case is the old behavior (the FSM still
62
+ * detects the trust modal as an approval state), not a crash.
63
+ *
64
+ * Returns the path that was added (realpath), or null if nothing changed /
65
+ * an error occurred — purely so callers/tests can assert the effect.
66
+ */
67
+ export function applyPreLaunchTrust(trust: PreLaunchTrust, workingDir: string): string | null {
68
+ const settingsPath = expandHome(trust.settings_path);
69
+ const key = trust.key;
70
+ const real = realWorkspacePath(workingDir);
71
+ try {
72
+ let parsed: Record<string, unknown> = {};
73
+ if (fs.existsSync(settingsPath)) {
74
+ const text = fs.readFileSync(settingsPath, 'utf8');
75
+ if (text.trim().length > 0) {
76
+ const json = JSON.parse(text);
77
+ if (json && typeof json === 'object' && !Array.isArray(json)) {
78
+ parsed = json as Record<string, unknown>;
79
+ }
80
+ }
81
+ }
82
+
83
+ const existing = parsed[key];
84
+ const list: string[] = Array.isArray(existing)
85
+ ? existing.filter((v): v is string => typeof v === 'string')
86
+ : [];
87
+
88
+ if (list.includes(real)) {
89
+ LOG.debug('pre-launch-trust', `[${trust.settings_path}] ${real} already trusted — no change`);
90
+ return null;
91
+ }
92
+
93
+ list.push(real);
94
+ parsed[key] = list;
95
+
96
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
97
+ fs.writeFileSync(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
98
+ LOG.info('pre-launch-trust', `pre-trusted workspace in ${trust.settings_path} (key="${key}")`);
99
+ return real;
100
+ } catch (err) {
101
+ LOG.warn('pre-launch-trust', `failed to pre-trust workspace in ${trust.settings_path}: ${(err as Error).message}`);
102
+ return null;
103
+ }
104
+ }
@@ -137,7 +137,14 @@ export interface ClaudeInteractiveTuiPage {
137
137
  header?: string;
138
138
  }
139
139
 
140
- const CLAUDE_TUI_OPTION_PATTERN = /^\s*(?:[❯›>]\s*)?(\d+)\.\s+(.+?)\s*$/;
140
+ // Option rows look like "❯ 1. Label". The multi-select picker additionally
141
+ // draws a checkbox marker that can sit before or after the number
142
+ // ("❯ [ ] 1. Label" / "❯ 1. [x] Label" / "☐ 1. Label"); the optional,
143
+ // non-capturing checkbox groups absorb it so the captured label stays clean.
144
+ const CLAUDE_TUI_OPTION_CHECKBOX = '(?:\\[[ xX]\\]|[☐☒◻◼])';
145
+ const CLAUDE_TUI_OPTION_PATTERN = new RegExp(
146
+ `^\\s*(?:[❯›>]\\s*)?(?:${CLAUDE_TUI_OPTION_CHECKBOX}\\s*)?(\\d+)\\.\\s+(?:${CLAUDE_TUI_OPTION_CHECKBOX}\\s*)?(.+?)\\s*$`,
147
+ );
141
148
 
142
149
  function claudeTuiQuestionHeaders(screenText: string): string[] {
143
150
  const navLine = screenText.split(/\r?\n/).find(line => line.includes('✔ Submit') && /[☐☒]/.test(line));
@@ -155,6 +162,41 @@ function isClaudeTuiSelectFooter(text: string): boolean {
155
162
  return /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
156
163
  }
157
164
 
165
+ /**
166
+ * Decide whether a captured claude-cli AskUserQuestion TUI page is multi-select.
167
+ *
168
+ * The original heuristic only matched the footer hint `/Space to select|toggle
169
+ * selections/i`. That string drifts between claude-cli versions, so when it
170
+ * changed the dashboard silently fell back to multiSelect:false and rendered
171
+ * single-select (radio) controls even though the on-screen picker showed
172
+ * checkboxes — the user could not check more than one box. (The CLI's own
173
+ * terminal still rendered `[ ]` correctly because it never depends on this
174
+ * parse.)
175
+ *
176
+ * Make detection robust by ALSO recognising the actual checkbox markers the
177
+ * multi-select picker draws on its option rows (`[ ]` / `[x]` / `☐` / `☒` /
178
+ * `◻` / `◼`). Single-select rows are drawn with a `❯`/number cursor only and
179
+ * carry none of these box glyphs, so their presence is a reliable signal. The
180
+ * broadened footer patterns ("Space to", "toggle", "select multiple") are kept
181
+ * as a secondary signal for layouts that render markers differently.
182
+ */
183
+ function detectClaudeTuiMultiSelect(screenText: string): boolean {
184
+ if (/Space to (?:select|toggle)|toggle selection|select multiple|select all that apply/i.test(screenText)) {
185
+ return true;
186
+ }
187
+ // A checkbox glyph sitting in front of a NUMBERED option row only appears in
188
+ // the multi-select picker (e.g. "❯ [ ] 1. TypeScript" / "☐ 2. Python"). We
189
+ // require the numbered "N." option marker so we don't false-positive on the
190
+ // `✔ Submit` nav line (per-question answered-state ☐/☒) or on the headerless
191
+ // variant where the QUESTION line itself begins with `☐ ` (single-select).
192
+ const optionCheckbox = /^\s*(?:[❯›>]\s*)?(?:\[[ xX]\]|[☐☒◻◼])\s*\d+\.\s+\S/;
193
+ for (const line of screenText.split(/\r?\n/)) {
194
+ if (line.includes('✔ Submit')) continue; // header/nav line
195
+ if (optionCheckbox.test(line)) return true;
196
+ }
197
+ return false;
198
+ }
199
+
158
200
  function readClaudeHeaderLine(lines: string[], beforeIndex: number): string | undefined {
159
201
  for (let i = beforeIndex; i >= 0; i -= 1) {
160
202
  const candidate = lines[i].trim();
@@ -254,7 +296,7 @@ function parseClaudeHeaderlessInteractiveTuiQuestion(page: ClaudeInteractiveTuiP
254
296
  questionId: `q${index + 1}`,
255
297
  question,
256
298
  ...(header ? { header } : {}),
257
- multiSelect: /Space to select|toggle selections/i.test(page.screenText),
299
+ multiSelect: detectClaudeTuiMultiSelect(page.screenText),
258
300
  options,
259
301
  ...(allowFreeform ? { allowFreeform: true } : {}),
260
302
  };
@@ -313,7 +355,7 @@ function parseClaudeInteractiveTuiQuestion(page: ClaudeInteractiveTuiPage, index
313
355
  questionId: `q${index + 1}`,
314
356
  question,
315
357
  ...(header ? { header } : {}),
316
- multiSelect: /Space to select|toggle selections/i.test(page.screenText),
358
+ multiSelect: detectClaudeTuiMultiSelect(page.screenText),
317
359
  options,
318
360
  ...(allowFreeform ? { allowFreeform: true } : {}),
319
361
  };