@adhdev/daemon-core 0.9.82-rc.269 → 0.9.82-rc.270

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.
@@ -3,6 +3,37 @@ export declare function readNonEmptyString(value: unknown): string;
3
3
  export declare function readRecord(value: unknown): Record<string, unknown> | undefined;
4
4
  export declare function canonicalDaemonId(value: unknown): string;
5
5
  export declare function sameDaemonId(a: unknown, b: unknown): boolean;
6
+ /**
7
+ * The relay-safety metadata a worker session must carry so that its completion /
8
+ * generating events route back to the coordinator proactively (without waiting for
9
+ * a mesh_read_chat reconcile). meshCoordinatorDaemonId is the routing anchor the
10
+ * core forwarder (injectMeshSystemMessage) keys on to pick a remote coordinator
11
+ * target; meshNodeFor/meshNodeId identify the worker, and launchedByCoordinator is
12
+ * the delegation proof. See resolveWorkerDelegateRouting().
13
+ */
14
+ export interface MeshWorkerRelayStamp {
15
+ meshNodeFor?: string;
16
+ meshNodeId?: string;
17
+ meshCoordinatorDaemonId?: string;
18
+ launchedByCoordinator?: boolean;
19
+ }
20
+ /**
21
+ * Build the relay-safety stamp from a dispatch's meshContext, only including fields
22
+ * the session does not already carry. Returns undefined when there is nothing new to
23
+ * stamp, so callers can skip a no-op updateSettings() write.
24
+ *
25
+ * This closes the remote-session relay gap: a worker session reached by a dispatch
26
+ * that carries coordinatorDaemonId (mesh_send_task / queue assignment over P2P) gets
27
+ * the coordinator anchor persisted onto its settings at dispatch time, even if it was
28
+ * not launched via mesh_launch_session. Without the stamp the forwarder cannot resolve
29
+ * a remote coordinator and the completion event sits in the pending queue until a
30
+ * read_chat-triggered reconcile drains it.
31
+ */
32
+ export declare function buildMeshWorkerRelayStamp(currentSettings: Record<string, unknown> | undefined, meshContext: {
33
+ meshId?: unknown;
34
+ nodeId?: unknown;
35
+ coordinatorDaemonId?: unknown;
36
+ } | undefined): MeshWorkerRelayStamp | undefined;
6
37
  export declare function resolveEventSessionId(event: Record<string, unknown>, fallback?: unknown): string;
7
38
  export declare function readRefineJobId(event: {
8
39
  metadataEvent?: Record<string, unknown>;
@@ -186,6 +186,7 @@ export declare class FsmDriver implements ISpecDriver {
186
186
  constructor(opts: SpecDriverOpts);
187
187
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
188
188
  start(): void;
189
+ private scheduleSpawnPrime;
189
190
  dispatch(cmd: DashboardCommand): void;
190
191
  snapshot(): string;
191
192
  getCursorPosition(): {
@@ -66,6 +66,21 @@ export interface CliSpecV4 {
66
66
  spawn_args?: string[];
67
67
  env?: Record<string, string>;
68
68
  cli_version_range?: string;
69
+ /**
70
+ * Optional raw byte sequences written to the PTY once, shortly after spawn,
71
+ * to prime a TUI that gates its input handling on a terminal event the
72
+ * daemon would otherwise never emit. The canonical case is a focus-event
73
+ * TUI (Ink `useStdin`/`useFocus`, e.g. antigravity's `agy`) that enables
74
+ * focus reporting (`CSI ?1004h`) and treats its input box as unfocused —
75
+ * silently dropping the first programmatic write — until it receives a
76
+ * focus-in event (`ESC [ I`). Declaring `[""]` here wakes the input
77
+ * stream on spawn so the first delegated message lands without a manual
78
+ * keystroke. CLIs that do not focus-gate input simply omit this field; the
79
+ * engine writes nothing extra for them, so it stays CLI-agnostic.
80
+ */
81
+ send_on_spawn?: string[];
82
+ /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
83
+ send_on_spawn_delay_ms?: number;
69
84
  send_message: {
70
85
  submit_key: string;
71
86
  delay_ms_before_submit?: number;
@@ -75,19 +75,34 @@ export interface NativeHistoryMessageMap {
75
75
  timestamp_ms?: string;
76
76
  kind?: string;
77
77
  }
78
+ export interface AnchorContext {
79
+ prev?: string;
80
+ next?: string;
81
+ prev_flags?: string;
82
+ next_flags?: string;
83
+ }
78
84
  export interface SectionDef {
79
85
  from_top?: Size;
80
86
  from_bottom?: Size;
81
87
  until?: string;
82
- anchor?: string;
88
+ /**
89
+ * Anchor regex(es). A single string anchors on the first/last matching line
90
+ * (per `anchor_last`). An array is an OR-set: every candidate line is one
91
+ * that matches ANY entry; with `anchor_last` the LAST such line across all
92
+ * patterns wins, otherwise the FIRST. This lets one section capture two
93
+ * different shapes — e.g. a box-divider modal AND a divider-less modal whose
94
+ * only stable landmark is the question line above its numbered choices.
95
+ */
96
+ anchor?: string | string[];
83
97
  anchor_flags?: string;
84
98
  anchor_last?: boolean;
85
- anchor_context?: {
86
- prev?: string;
87
- next?: string;
88
- prev_flags?: string;
89
- next_flags?: string;
90
- };
99
+ /**
100
+ * Context guard(s) for the anchor. A single object applies to every anchor
101
+ * pattern. An array is matched positionally against an `anchor` array (entry
102
+ * i guards anchor i); a positional `undefined`/null entry means "no guard"
103
+ * for that anchor. A scalar `anchor` ignores array form beyond index 0.
104
+ */
105
+ anchor_context?: AnchorContext | (AnchorContext | null)[];
91
106
  lines?: number;
92
107
  until_regex?: string;
93
108
  until_regex_flags?: string;
@@ -99,6 +99,17 @@ export interface RepoMeshPolicy {
99
99
  * watch-the-agents behavior; hidden sessions remain discoverable and manually openable.
100
100
  */
101
101
  spawnedSessionVisibility?: RepoMeshSpawnedSessionVisibility;
102
+ /**
103
+ * Whether worker sessions the coordinator dispatches should auto-approve agent
104
+ * approval modals (tool/command prompts) without firing a user-facing approval
105
+ * notification. Delegated workers are coordinator-driven, so a human should not
106
+ * have to approve each one; defaults to true. Set to false to make delegated
107
+ * worker sessions stop at approval modals like an interactive session.
108
+ * Stamped into the worker launch settings envelope as `autoApprove`, which wins
109
+ * over the global per-provider-type autoApprove config via the settings merge.
110
+ * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
111
+ */
112
+ delegatedWorkerAutoApprove?: boolean;
102
113
  /**
103
114
  * What to do with delegated session-host records for a node when it is removed.
104
115
  * Defaults to 'preserve' so completed work can be reviewed later and live
@@ -129,6 +140,11 @@ export interface RepoMeshNodePolicy {
129
140
  maxConcurrentSessions?: number;
130
141
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
131
142
  providerPriority?: string[];
143
+ /**
144
+ * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
145
+ * precedence over the mesh-level policy for worker sessions launched onto this node.
146
+ */
147
+ delegatedWorkerAutoApprove?: boolean;
132
148
  /**
133
149
  * Optional associated/external repos that must be checked alongside this node.
134
150
  * These are explicit policy/config entries only; Repo Mesh does not auto-discover
@@ -152,6 +168,14 @@ export interface RepoMeshNodePolicy {
152
168
  initSubmodulesOnClone?: boolean;
153
169
  }
154
170
  export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
171
+ /**
172
+ * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
173
+ * governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
174
+ * → default true. The result is stamped into the worker launch settings envelope as
175
+ * `autoApprove`; it wins over the global per-provider-type autoApprove config because the
176
+ * launch path merges the envelope as a settingsOverride on top of the provider defaults.
177
+ */
178
+ export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
155
179
  export interface RepoMeshNodeCapabilities {
156
180
  platform?: string;
157
181
  packageManagers?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.269",
3
+ "version": "0.9.82-rc.270",
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.269",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.270",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -45,6 +45,7 @@ import { buildSessionEntries } from '../status/builders.js';
45
45
  import { registerMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
46
46
  import { handleMeshForwardEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent, type PendingMeshCoordinatorEvent } from '../mesh/mesh-events.js';
47
47
  import { getRecentUnroutableDeliveries } from '../mesh/mesh-routing.js';
48
+ import { buildMeshWorkerRelayStamp } from '../mesh/mesh-events-utils.js';
48
49
  import { buildMeshHostRequiredFailure, normalizeMeshDaemonRole, resolveMeshHostStatus } from '../mesh/mesh-host-ownership.js';
49
50
  import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
50
51
  import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
@@ -5307,6 +5308,34 @@ export class DaemonCommandRouter {
5307
5308
  return this.deps.cliManager.handleCliCommand(cmd, args);
5308
5309
  }
5309
5310
  case 'agent_command': {
5311
+ // Relay-safety stamp: a dispatch carrying meshContext.coordinatorDaemonId
5312
+ // (mesh_send_task / queue assignment over P2P) is the worker daemon's chance
5313
+ // to persist the coordinator routing anchor onto the target session BEFORE the
5314
+ // turn runs. Without meshCoordinatorDaemonId on the session, the core forwarder
5315
+ // (injectMeshSystemMessage) cannot resolve a remote coordinator target, so the
5316
+ // completion event sits in the pending queue until a read_chat reconcile drains
5317
+ // it. Stamping here makes a reused/relaunched remote session relay-safe at
5318
+ // dispatch time even when it was not launched via mesh_launch_session.
5319
+ {
5320
+ const dispatchSessionId = readStringValue(args?.targetSessionId, (args as any)?.sessionId, (args as any)?.instanceId);
5321
+ const dispatchMeshContext = args?.meshContext as Record<string, unknown> | undefined;
5322
+ if (dispatchSessionId && dispatchMeshContext) {
5323
+ try {
5324
+ const inst = this.deps.instanceManager.getInstance(dispatchSessionId);
5325
+ if (inst && typeof inst.updateSettings === 'function') {
5326
+ const stamp = buildMeshWorkerRelayStamp(
5327
+ inst.getState?.()?.settings as Record<string, unknown> | undefined,
5328
+ {
5329
+ meshId: dispatchMeshContext.meshId,
5330
+ nodeId: dispatchMeshContext.nodeId,
5331
+ coordinatorDaemonId: dispatchMeshContext.coordinatorDaemonId,
5332
+ },
5333
+ );
5334
+ if (stamp) inst.updateSettings(stamp);
5335
+ }
5336
+ } catch { /* best-effort — dispatch still proceeds without the stamp */ }
5337
+ }
5338
+ }
5310
5339
  const agentResult = await this.deps.cliManager.handleCliCommand(cmd, args);
5311
5340
  // Bug C fix (part 2): when dispatching a task to a mesh node session, override
5312
5341
  // the dispatch acknowledgement risk reason to 'bootstrap_still_running' when
package/src/index.ts CHANGED
@@ -132,7 +132,7 @@ export type {
132
132
  RepoMeshLedgerStatus,
133
133
  MeshAsyncJobLifecycle,
134
134
  } from './repo-mesh-types.js';
135
- export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
135
+ export { DEFAULT_MESH_POLICY, resolveDelegatedWorkerAutoApprove } from './repo-mesh-types.js';
136
136
 
137
137
  // ── Git Surface ──
138
138
  export * from './git/index.js';
@@ -13,6 +13,7 @@ import { MeshRuntimeStore } from './mesh-runtime-store.js';
13
13
  import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, markMeshCoordinatorEventDirectDelivered } from './mesh-events-pending.js';
14
14
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
15
15
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent } from './mesh-routing.js';
16
+ import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
16
17
  import {
17
18
  findRecentTerminalLedgerEvidence,
18
19
  hasDispatchAfterTerminal,
@@ -302,7 +303,24 @@ export function tryAssignQueueTask(
302
303
  try {
303
304
  const inst = components.instanceManager.getInstance(sessionId);
304
305
  if (inst && typeof inst.updateSettings === 'function') {
305
- inst.updateSettings({ meshNodeFor: meshId, meshNodeId: nodeId, launchedByCoordinator: true });
306
+ // Adopting a (possibly manually-opened) local session as a worker: apply the
307
+ // delegated-worker auto-approve policy here too, so a session that was launched
308
+ // without autoApprove still auto-approves once the coordinator dispatches a task
309
+ // to it (the "approval notification fires only for certain delegated sessions"
310
+ // case). updateSettings preserves runtime mesh keys; passing autoApprove keeps it.
311
+ //
312
+ // This local-dispatch branch also runs on the coordinator daemon for a co-located
313
+ // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
314
+ // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
315
+ // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
316
+ const localDaemonId = readNonEmptyString(loadConfig().machineId);
317
+ inst.updateSettings({
318
+ meshNodeFor: meshId,
319
+ meshNodeId: nodeId,
320
+ launchedByCoordinator: true,
321
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
322
+ ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
323
+ });
306
324
  }
307
325
  } catch { /* best-effort — dispatch still proceeds */ }
308
326
 
@@ -666,6 +684,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
666
684
  meshNodeFor: meshId,
667
685
  meshNodeId: nodeId,
668
686
  spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
687
+ // Coordinator-dispatched worker: auto-approve unless mesh/node policy
688
+ // opts out (default true). Lands in settingsOverride and beats the
689
+ // global per-provider-type autoApprove config (see shouldAutoApprove).
690
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
669
691
  launchedByCoordinator: true,
670
692
  autoLaunchedForQueueTaskId: task.id,
671
693
  },
@@ -1343,6 +1365,9 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1343
1365
  meshNodeFor: args.meshId,
1344
1366
  meshNodeId: node.id,
1345
1367
  spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
1368
+ // Coordinator-dispatched recovery relaunch: same auto-approve
1369
+ // policy as the primary worker launch path.
1370
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
1346
1371
  launchedByCoordinator: true,
1347
1372
  }
1348
1373
  }).catch((e: any) => LOG.error('MeshRecovery', `Failed to auto-relaunch session for ${node.id}: ${e?.message}`));
@@ -30,6 +30,65 @@ export function sameDaemonId(a: unknown, b: unknown): boolean {
30
30
  return ca !== '' && ca === cb;
31
31
  }
32
32
 
33
+ /**
34
+ * The relay-safety metadata a worker session must carry so that its completion /
35
+ * generating events route back to the coordinator proactively (without waiting for
36
+ * a mesh_read_chat reconcile). meshCoordinatorDaemonId is the routing anchor the
37
+ * core forwarder (injectMeshSystemMessage) keys on to pick a remote coordinator
38
+ * target; meshNodeFor/meshNodeId identify the worker, and launchedByCoordinator is
39
+ * the delegation proof. See resolveWorkerDelegateRouting().
40
+ */
41
+ export interface MeshWorkerRelayStamp {
42
+ meshNodeFor?: string;
43
+ meshNodeId?: string;
44
+ meshCoordinatorDaemonId?: string;
45
+ launchedByCoordinator?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Build the relay-safety stamp from a dispatch's meshContext, only including fields
50
+ * the session does not already carry. Returns undefined when there is nothing new to
51
+ * stamp, so callers can skip a no-op updateSettings() write.
52
+ *
53
+ * This closes the remote-session relay gap: a worker session reached by a dispatch
54
+ * that carries coordinatorDaemonId (mesh_send_task / queue assignment over P2P) gets
55
+ * the coordinator anchor persisted onto its settings at dispatch time, even if it was
56
+ * not launched via mesh_launch_session. Without the stamp the forwarder cannot resolve
57
+ * a remote coordinator and the completion event sits in the pending queue until a
58
+ * read_chat-triggered reconcile drains it.
59
+ */
60
+ export function buildMeshWorkerRelayStamp(
61
+ currentSettings: Record<string, unknown> | undefined,
62
+ meshContext: {
63
+ meshId?: unknown;
64
+ nodeId?: unknown;
65
+ coordinatorDaemonId?: unknown;
66
+ } | undefined,
67
+ ): MeshWorkerRelayStamp | undefined {
68
+ if (!meshContext) return undefined;
69
+ const settings = currentSettings && typeof currentSettings === 'object' ? currentSettings : {};
70
+ const stamp: MeshWorkerRelayStamp = {};
71
+
72
+ const meshId = readNonEmptyString(meshContext.meshId);
73
+ if (meshId && !readNonEmptyString(settings.meshNodeFor)) stamp.meshNodeFor = meshId;
74
+
75
+ const nodeId = readNonEmptyString(meshContext.nodeId);
76
+ if (nodeId && !readNonEmptyString(settings.meshNodeId)) stamp.meshNodeId = nodeId;
77
+
78
+ const coordinatorDaemonId = readNonEmptyString(meshContext.coordinatorDaemonId);
79
+ if (coordinatorDaemonId && !readNonEmptyString(settings.meshCoordinatorDaemonId)) {
80
+ stamp.meshCoordinatorDaemonId = coordinatorDaemonId;
81
+ }
82
+
83
+ // A dispatch from a coordinator is itself proof of delegation; stamp it when the
84
+ // session is being given any mesh routing context but has no marker yet.
85
+ if ((meshId || nodeId || coordinatorDaemonId) && settings.launchedByCoordinator !== true) {
86
+ stamp.launchedByCoordinator = true;
87
+ }
88
+
89
+ return Object.keys(stamp).length > 0 ? stamp : undefined;
90
+ }
91
+
33
92
  export function resolveEventSessionId(event: Record<string, unknown>, fallback?: unknown): string {
34
93
  return readNonEmptyString(event.targetSessionId)
35
94
  || readNonEmptyString(event.sessionId)
@@ -8,7 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  import type {
11
- SectionDef, Condition, RegexCondition,
11
+ SectionDef, AnchorContext, Condition, RegexCondition,
12
12
  ChangedCondition, AllCondition, AnyCondition,
13
13
  ExtractTitle, ExtractButtons,
14
14
  } from './types.js';
@@ -52,20 +52,44 @@ export function resolveSections(
52
52
 
53
53
  if (sec.anchor !== undefined) {
54
54
  try {
55
- const re = new RegExp(sec.anchor, sec.anchor_flags ?? '');
56
- const prevRe = sec.anchor_context?.prev !== undefined
57
- ? new RegExp(sec.anchor_context.prev, sec.anchor_context.prev_flags ?? '') : null;
58
- const nextRe = sec.anchor_context?.next !== undefined
59
- ? new RegExp(sec.anchor_context.next, sec.anchor_context.next_flags ?? '') : null;
60
- const matches = (i: number) =>
61
- re.test(lines[i])
62
- && (prevRe === null || (i > 0 && prevRe.test(lines[i - 1])))
63
- && (nextRe === null || (i < total - 1 && nextRe.test(lines[i + 1])));
55
+ // Normalize anchor + context into parallel candidate lists. A
56
+ // scalar anchor becomes a single-entry array; a single context
57
+ // object applies to every entry; an array context is positional.
58
+ // Candidates are tried IN ORDER as independent passes: the first
59
+ // pattern that finds any line wins. This keeps a scalar anchor's
60
+ // behavior identical, and lets an array express a preferred shape
61
+ // (e.g. a box divider) with later entries as fallbacks (e.g. a
62
+ // divider-less modal anchored on its question line) the
63
+ // fallback only takes over when the preferred pattern is absent.
64
+ const anchorPatterns = Array.isArray(sec.anchor) ? sec.anchor : [sec.anchor];
65
+ const sharedCtx: AnchorContext | null = Array.isArray(sec.anchor_context)
66
+ ? null
67
+ : (sec.anchor_context ?? null);
68
+ const ctxList: (AnchorContext | null)[] = Array.isArray(sec.anchor_context)
69
+ ? sec.anchor_context
70
+ : anchorPatterns.map(() => sharedCtx);
71
+ const candidates = anchorPatterns.map((pattern, k) => {
72
+ const ctx = ctxList[k] ?? null;
73
+ return {
74
+ re: new RegExp(pattern, sec.anchor_flags ?? ''),
75
+ prevRe: ctx?.prev !== undefined
76
+ ? new RegExp(ctx.prev, ctx.prev_flags ?? '') : null,
77
+ nextRe: ctx?.next !== undefined
78
+ ? new RegExp(ctx.next, ctx.next_flags ?? '') : null,
79
+ };
80
+ });
81
+ const matchesCandidate = (c: typeof candidates[number], i: number) =>
82
+ c.re.test(lines[i])
83
+ && (c.prevRe === null || (i > 0 && c.prevRe.test(lines[i - 1])))
84
+ && (c.nextRe === null || (i < total - 1 && c.nextRe.test(lines[i + 1])));
64
85
  let idx = -1;
65
- if (sec.anchor_last) {
66
- for (let i = total - 1; i >= 0; i--) { if (matches(i)) { idx = i; break; } }
67
- } else {
68
- for (let i = 0; i < total; i++) { if (matches(i)) { idx = i; break; } }
86
+ for (const c of candidates) {
87
+ if (sec.anchor_last) {
88
+ for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) { idx = i; break; } }
89
+ } else {
90
+ for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { idx = i; break; } }
91
+ }
92
+ if (idx !== -1) break;
69
93
  }
70
94
  if (idx !== -1) {
71
95
  from = idx;
@@ -246,11 +246,27 @@ export class FsmDriver implements ISpecDriver {
246
246
  this.stateEnteredAt = now;
247
247
  this.prevStateAt = now;
248
248
  this.adapter.start();
249
+ // Prime focus-gated TUIs (see CliSpecV4.send_on_spawn). Written once,
250
+ // shortly after spawn, so the input stream is awake before the first
251
+ // delegated message — without this, a focus-event CLI like antigravity
252
+ // drops the first programmatic write until a manual keystroke.
253
+ this.scheduleSpawnPrime();
249
254
  // The initial state may have a purely time-based exit (elapsed_ms);
250
255
  // schedule a wake so we leave it even if the PTY goes quiet.
251
256
  this.scheduleWakeForState();
252
257
  }
253
258
 
259
+ private scheduleSpawnPrime(): void {
260
+ const seqs = this.spec.send_on_spawn;
261
+ if (!Array.isArray(seqs) || seqs.length === 0) return;
262
+ const delay = Math.max(0, this.spec.send_on_spawn_delay_ms ?? 250);
263
+ setTimeout(() => {
264
+ for (const seq of seqs) {
265
+ if (typeof seq === 'string' && seq.length > 0) this.adapter.send_keys(seq);
266
+ }
267
+ }, delay);
268
+ }
269
+
254
270
  dispatch(cmd: DashboardCommand): void {
255
271
  switch (cmd.kind) {
256
272
  case 'send_message': this.handleSendMessage(cmd.text); return;
@@ -440,6 +456,18 @@ export class FsmDriver implements ISpecDriver {
440
456
  // that's already satisfied doesn't wait for the next PTY frame.
441
457
  this.emitStateChanged(forceEmit);
442
458
  this.scheduleWakeForState();
459
+ // Drain queued sends on the SAME frame the machine reaches "ready".
460
+ // The first delegated message is queued in pendingSends until the
461
+ // FSM first enters a non-initial idle state (the prompt is drawn).
462
+ // That readiness is normally reached BY a transition (e.g.
463
+ // signing_in→idle / starting→idle) — and an idle state has no
464
+ // pending time-condition, so scheduleWakeForState() arms no timer
465
+ // and, agy being quiet at the prompt, no further PTY frame arrives.
466
+ // Without this call the queued first message would strand here
467
+ // forever (the "first input never processed" bug). maybeMarkReady is
468
+ // idempotent (guarded by readySeenOnce) so calling it on both
469
+ // branches is safe.
470
+ this.maybeMarkReady();
443
471
  return;
444
472
  }
445
473
 
@@ -130,6 +130,21 @@ export interface CliSpecV4 {
130
130
  spawn_args?: string[];
131
131
  env?: Record<string, string>;
132
132
  cli_version_range?: string;
133
+ /**
134
+ * Optional raw byte sequences written to the PTY once, shortly after spawn,
135
+ * to prime a TUI that gates its input handling on a terminal event the
136
+ * daemon would otherwise never emit. The canonical case is a focus-event
137
+ * TUI (Ink `useStdin`/`useFocus`, e.g. antigravity's `agy`) that enables
138
+ * focus reporting (`CSI ?1004h`) and treats its input box as unfocused —
139
+ * silently dropping the first programmatic write — until it receives a
140
+ * focus-in event (`ESC [ I`). Declaring `[""]` here wakes the input
141
+ * stream on spawn so the first delegated message lands without a manual
142
+ * keystroke. CLIs that do not focus-gate input simply omit this field; the
143
+ * engine writes nothing extra for them, so it stays CLI-agnostic.
144
+ */
145
+ send_on_spawn?: string[];
146
+ /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
147
+ send_on_spawn_delay_ms?: number;
133
148
  send_message: {
134
149
  submit_key: string;
135
150
  delay_ms_before_submit?: number;
@@ -104,19 +104,35 @@ export interface NativeHistoryMessageMap {
104
104
  // Section definition
105
105
  // ─────────────────────────────────────────────────────────────────────────────
106
106
 
107
+ export interface AnchorContext {
108
+ prev?: string;
109
+ next?: string;
110
+ prev_flags?: string;
111
+ next_flags?: string;
112
+ }
113
+
107
114
  export interface SectionDef {
108
115
  from_top?: Size;
109
116
  from_bottom?: Size;
110
117
  until?: string; // section id OR regex (starts with ^)
111
- anchor?: string;
118
+ /**
119
+ * Anchor regex(es). A single string anchors on the first/last matching line
120
+ * (per `anchor_last`). An array is an OR-set: every candidate line is one
121
+ * that matches ANY entry; with `anchor_last` the LAST such line across all
122
+ * patterns wins, otherwise the FIRST. This lets one section capture two
123
+ * different shapes — e.g. a box-divider modal AND a divider-less modal whose
124
+ * only stable landmark is the question line above its numbered choices.
125
+ */
126
+ anchor?: string | string[];
112
127
  anchor_flags?: string;
113
128
  anchor_last?: boolean;
114
- anchor_context?: {
115
- prev?: string;
116
- next?: string;
117
- prev_flags?: string;
118
- next_flags?: string;
119
- };
129
+ /**
130
+ * Context guard(s) for the anchor. A single object applies to every anchor
131
+ * pattern. An array is matched positionally against an `anchor` array (entry
132
+ * i guards anchor i); a positional `undefined`/null entry means "no guard"
133
+ * for that anchor. A scalar `anchor` ignores array form beyond index 0.
134
+ */
135
+ anchor_context?: AnchorContext | (AnchorContext | null)[];
120
136
  lines?: number;
121
137
  until_regex?: string;
122
138
  until_regex_flags?: string;
@@ -120,6 +120,17 @@ export interface RepoMeshPolicy {
120
120
  * watch-the-agents behavior; hidden sessions remain discoverable and manually openable.
121
121
  */
122
122
  spawnedSessionVisibility?: RepoMeshSpawnedSessionVisibility;
123
+ /**
124
+ * Whether worker sessions the coordinator dispatches should auto-approve agent
125
+ * approval modals (tool/command prompts) without firing a user-facing approval
126
+ * notification. Delegated workers are coordinator-driven, so a human should not
127
+ * have to approve each one; defaults to true. Set to false to make delegated
128
+ * worker sessions stop at approval modals like an interactive session.
129
+ * Stamped into the worker launch settings envelope as `autoApprove`, which wins
130
+ * over the global per-provider-type autoApprove config via the settings merge.
131
+ * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
132
+ */
133
+ delegatedWorkerAutoApprove?: boolean;
123
134
  /**
124
135
  * What to do with delegated session-host records for a node when it is removed.
125
136
  * Defaults to 'preserve' so completed work can be reviewed later and live
@@ -152,6 +163,11 @@ export interface RepoMeshNodePolicy {
152
163
  maxConcurrentSessions?: number;
153
164
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
154
165
  providerPriority?: string[];
166
+ /**
167
+ * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
168
+ * precedence over the mesh-level policy for worker sessions launched onto this node.
169
+ */
170
+ delegatedWorkerAutoApprove?: boolean;
155
171
  /**
156
172
  * Optional associated/external repos that must be checked alongside this node.
157
173
  * These are explicit policy/config entries only; Repo Mesh does not auto-discover
@@ -184,11 +200,32 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
184
200
  dirtyWorkspaceBehavior: 'warn',
185
201
  maxParallelTasks: 2,
186
202
  spawnedSessionVisibility: 'visible',
203
+ delegatedWorkerAutoApprove: true,
187
204
  sessionCleanupOnNodeRemove: 'preserve',
188
205
  autoFastForward: { enabled: true },
189
206
  maxTaskRetries: 1,
190
207
  };
191
208
 
209
+ /**
210
+ * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
211
+ * governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
212
+ * → default true. The result is stamped into the worker launch settings envelope as
213
+ * `autoApprove`; it wins over the global per-provider-type autoApprove config because the
214
+ * launch path merges the envelope as a settingsOverride on top of the provider defaults.
215
+ */
216
+ export function resolveDelegatedWorkerAutoApprove(
217
+ meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null,
218
+ nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null,
219
+ ): boolean {
220
+ if (typeof nodePolicy?.delegatedWorkerAutoApprove === 'boolean') {
221
+ return nodePolicy.delegatedWorkerAutoApprove;
222
+ }
223
+ if (typeof meshPolicy?.delegatedWorkerAutoApprove === 'boolean') {
224
+ return meshPolicy.delegatedWorkerAutoApprove;
225
+ }
226
+ return true;
227
+ }
228
+
192
229
  // ─── Capabilities ───────────────────────────────
193
230
 
194
231
  export interface RepoMeshNodeCapabilities {