@adhdev/daemon-core 0.9.82-rc.287 → 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.287",
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.287",
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
  );
@@ -12,7 +12,7 @@ import { createSessionDelivery, markSessionDeliveriesTerminal, updateSessionDeli
12
12
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
13
13
  import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
14
14
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
15
- import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent } from './mesh-routing.js';
15
+ import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
16
16
  import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
17
17
  import {
18
18
  findRecentTerminalLedgerEvidence,
@@ -1446,11 +1446,16 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1446
1446
  if (!isMeshCoordinatorEvent(eventName)) {
1447
1447
  return { success: false, error: 'unsupported mesh event' };
1448
1448
  }
1449
- const meshId = readNonEmptyString(payload.meshId);
1450
- if (!meshId) return { success: false, error: 'meshId required' };
1451
-
1452
1449
  const nodeId = readNonEmptyString(payload.nodeId);
1453
1450
  const workspace = readNonEmptyString(payload.workspace);
1451
+
1452
+ // The fallback worker-forward path (forwardUnresolvedDelegateEvent) cannot resolve a
1453
+ // mesh id locally on the remote worker, so it forwards the event with workspace only.
1454
+ // The coordinator hosting the mesh CAN resolve it: recover the mesh id by workspace
1455
+ // when the payload doesn't carry one.
1456
+ const meshId = readNonEmptyString(payload.meshId)
1457
+ || (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '');
1458
+ if (!meshId) return { success: false, error: 'meshId required' };
1454
1459
  const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
1455
1460
  const relayModalMessage = readNonEmptyString(payload.modalMessage);
1456
1461
  const relayModalButtons = Array.isArray(payload.modalButtons)
@@ -1493,6 +1498,67 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1493
1498
  });
1494
1499
  }
1495
1500
 
1501
+ // ---------------------------------------------------------------------------
1502
+ // Worker-side fallback forward for unresolved-mesh delegates.
1503
+ //
1504
+ // A REMOTE worker daemon that is being P2P-remote-controlled by a coordinator is
1505
+ // NOT a member of the coordinator's mesh — it has no local mesh record. So when its
1506
+ // completion event reaches the forwarder, resolveWorkerDelegateRouting() resolves the
1507
+ // coordinator anchor (meshCoordinatorDaemonId) from the worker envelope but cannot
1508
+ // resolve the mesh id (neither meshNodeFor nor a workspace→mesh lookup yields one) and
1509
+ // returns isDelegate=false / mesh_unresolved. Before this fallback the event was dropped
1510
+ // (delivery_unroutable) and only recovered later when the coordinator happened to pull
1511
+ // the worker's queue — which it can't, because the worker never queued an unroutable
1512
+ // event. Live symptom: `WARN [MeshEvents] delivery_unroutable: ... mesh unresolved`.
1513
+ //
1514
+ // The fix: the routing object still carries coordinatorDaemonId. Forward the raw event
1515
+ // straight to that coordinator daemon over P2P (mesh_forward_event). The coordinator
1516
+ // hosts the mesh, so it recovers the mesh id by workspace in handleMeshForwardEvent and
1517
+ // injects/queues it normally. meshId is intentionally omitted from the payload (the
1518
+ // worker has none); workspace is the routing anchor the coordinator resolves from.
1519
+ //
1520
+ // No loop / no double-delivery:
1521
+ // - This only fires on the WORKER (the coordinator-own session is rejected by the
1522
+ // resolver before reaching here), and the coordinator merely injects — it does not
1523
+ // re-enter this forwarder for the relayed event.
1524
+ // - It fires only when the normal queue path did NOT run (isDelegate=false), so the
1525
+ // event is never both queued locally and forwarded.
1526
+ //
1527
+ // Returns true when the event was handed off to the coordinator daemon (so the caller
1528
+ // skips the delivery_unroutable diagnostic); false when no fallback was possible.
1529
+ function forwardUnresolvedDelegateEvent(
1530
+ components: DaemonComponents,
1531
+ routing: ReturnType<typeof resolveWorkerDelegateRouting>,
1532
+ event: Record<string, unknown>,
1533
+ ): boolean {
1534
+ const coordinatorDaemonId = readNonEmptyString(routing.coordinatorDaemonId);
1535
+ if (!coordinatorDaemonId) return false;
1536
+ if (!components.dispatchMeshCommand) return false;
1537
+
1538
+ const eventName = readNonEmptyString(event.event);
1539
+ if (!eventName) return false;
1540
+
1541
+ // Flat payload mirroring buildForwardPayloadFromPending / what handleMeshForwardEvent
1542
+ // reads. meshId is omitted on purpose — the worker can't resolve it; the coordinator
1543
+ // recovers it from workspace. nodeId/workspace come from the worker envelope so the
1544
+ // coordinator can name and locate the node.
1545
+ const payload: Record<string, unknown> = {
1546
+ ...event,
1547
+ event: eventName,
1548
+ nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId) || undefined,
1549
+ workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
1550
+ };
1551
+
1552
+ Promise.resolve(components.dispatchMeshCommand(coordinatorDaemonId, 'mesh_forward_event', payload))
1553
+ .catch((e: any) => {
1554
+ // The coordinator may be momentarily unreachable; the diagnostic was already
1555
+ // skipped, so leave a trace here so an operator can see the relay attempt failed.
1556
+ LOG.warn('MeshEvents', `Fallback forward of ${eventName} to coordinator ${coordinatorDaemonId} failed: ${e?.message || e}`);
1557
+ });
1558
+ LOG.info('MeshEvents', `Fallback-forwarded ${eventName} for unresolved-mesh worker at ${routing.workspace || '(no workspace)'} to coordinator daemon ${coordinatorDaemonId}`);
1559
+ return true;
1560
+ }
1561
+
1496
1562
  export function setupMeshEventForwarding(components: DaemonComponents) {
1497
1563
  components.instanceManager.onEvent((event) => {
1498
1564
  // --- Coordinator idle auto-flush (fast path) ---
@@ -1572,10 +1638,19 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1572
1638
  getMeshByWorkspace: (workspace) => getCachedMeshByWorkspace(workspace),
1573
1639
  });
1574
1640
  if (!routing.isDelegate) {
1575
- // R4: a worker that presented a valid envelope but resolved to no mesh used to be
1576
- // dropped silently. Leave a fail-loud diagnostic so the missing completion is
1577
- // traceable. Benign non-delegate rejections (not_cli / no_workspace / etc.) are
1578
- // no-ops inside recordUnroutableDelegateEvent.
1641
+ // Fallback: a REMOTE worker that isn't a member of the coordinator's mesh can't
1642
+ // resolve a mesh id locally (mesh_unresolved), but it still carries the coordinator
1643
+ // daemon anchor. Forward the event straight to that coordinator over P2P instead of
1644
+ // dropping it — the coordinator hosts the mesh and recovers the id by workspace.
1645
+ if (isUnroutableDelegateRejection(routing)
1646
+ && forwardUnresolvedDelegateEvent(components, routing, event)) {
1647
+ return;
1648
+ }
1649
+ // R4: a worker that presented a valid envelope but resolved to no mesh (and could
1650
+ // not be fallback-forwarded — e.g. no coordinator anchor) used to be dropped
1651
+ // silently. Leave a fail-loud diagnostic so the missing completion is traceable.
1652
+ // Benign non-delegate rejections (not_cli / no_workspace / etc.) are no-ops inside
1653
+ // recordUnroutableDelegateEvent.
1579
1654
  recordUnroutableDelegateEvent(routing, event.event);
1580
1655
  return;
1581
1656
  }
@@ -82,10 +82,13 @@ export function resolveWorkerDelegateRouting(
82
82
  const sessionId = readNonEmptyString(instanceId);
83
83
  let workspace = '';
84
84
  let coordinatorDaemonId = '';
85
+ // Runtime node-id stamp, surfaced even on rejection so the unresolved-mesh fallback
86
+ // forward can name the worker node for the coordinator.
87
+ let runtimeNodeId = '';
85
88
  const reject = (rejectionReason: WorkerDelegateRejectionReason): WorkerDelegateRouting => ({
86
89
  isDelegate: false,
87
90
  meshId: '',
88
- nodeId: '',
91
+ nodeId: runtimeNodeId,
89
92
  nodeLabel: '',
90
93
  coordinatorDaemonId,
91
94
  workspace,
@@ -102,6 +105,7 @@ export function resolveWorkerDelegateRouting(
102
105
 
103
106
  const settings = readSettings(state);
104
107
  coordinatorDaemonId = readNonEmptyString(settings.meshCoordinatorDaemonId);
108
+ runtimeNodeId = readNonEmptyString(settings.meshNodeId);
105
109
 
106
110
  // A coordinator session (meshCoordinatorFor set) is only treated as a worker delegate
107
111
  // when it is itself the target of an active direct dispatch — otherwise its own events
@@ -138,7 +142,6 @@ export function resolveWorkerDelegateRouting(
138
142
  if (!meshId) return reject('mesh_unresolved');
139
143
 
140
144
  const targetNode = mesh?.nodes?.find((n: any) => n.workspace === workspace);
141
- const runtimeNodeId = readNonEmptyString(settings.meshNodeId);
142
145
  const nodeId = readNonEmptyString(targetNode?.id) || runtimeNodeId;
143
146
  const nodeLabel = targetNode
144
147
  ? `Node '${targetNode.id}'`
@@ -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
  };