@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.
- package/dist/index.js +1019 -896
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1031 -908
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/fsm-types.d.ts +22 -0
- package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
- package/package.json +2 -2
- package/src/commands/router.ts +26 -0
- package/src/mesh/mesh-events-coordinator.ts +83 -8
- package/src/mesh/mesh-routing.ts +5 -2
- package/src/providers/spec/fsm-driver.ts +7 -0
- package/src/providers/spec/fsm-loader.ts +10 -0
- package/src/providers/spec/fsm-types.ts +23 -0
- package/src/providers/spec/pre-launch-trust.ts +104 -0
- package/src/providers/types/interactive-prompt.ts +45 -3
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
-
//
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
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
|
}
|
package/src/mesh/mesh-routing.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
358
|
+
multiSelect: detectClaudeTuiMultiSelect(page.screenText),
|
|
317
359
|
options,
|
|
318
360
|
...(allowFreeform ? { allowFreeform: true } : {}),
|
|
319
361
|
};
|