@adhdev/daemon-core 0.9.82-rc.356 → 0.9.82-rc.358

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.
@@ -65,6 +65,7 @@ export declare function triggerMeshQueue(components: DaemonComponents, meshId: s
65
65
  export declare function isMeshCoordinatorEvent(eventName: unknown): eventName is string;
66
66
  export declare const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string>;
67
67
  export declare function shouldForceInjectMeshEvent(eventName: unknown): boolean;
68
+ export declare function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown>;
68
69
  export declare function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>): {
69
70
  success: boolean;
70
71
  forwarded: number;
@@ -22,3 +22,24 @@ export declare function extractButtonsFromRule(rule: ExtractButtons, hay: string
22
22
  key: string;
23
23
  current: boolean;
24
24
  }[];
25
+ /**
26
+ * Reduce a top→bottom-ordered list of parsed numbered entries to only the
27
+ * bottom-most contiguous block — the run whose indices descend by exactly 1
28
+ * scanning upward from the last entry.
29
+ *
30
+ * Picker and approval choices always render as the LAST contiguous numbered
31
+ * block at the bottom of the modal section. The conversation history above can
32
+ * carry its own stray "1./2./3." numbered lists (and blockquote `>` lines), and
33
+ * because section anchoring can pull body lines into the modal section, a naive
34
+ * top-down scan would bind the low option indices to those body lines and drop
35
+ * the real choices (the dashboard would show, and an arrow-key picker would
36
+ * commit, the wrong row). Selecting the bottom block makes the on-screen picker
37
+ * win regardless of body content. Since pickers number their options 1..N
38
+ * contiguously and bodies use indices ≥ 1, the upward chain always breaks the
39
+ * moment it would need a "0." above the picker's "1." — so the picker block is
40
+ * isolated cleanly. Entries are assumed already in screen order; the returned
41
+ * slice keeps that order.
42
+ */
43
+ export declare function lastContiguousNumberedBlock<T extends {
44
+ index: number;
45
+ }>(entries: T[]): T[];
@@ -19,6 +19,7 @@ export type DashboardEvent = {
19
19
  index: number;
20
20
  label: string;
21
21
  }[];
22
+ kind: 'approval' | 'picker' | 'confirm' | null;
22
23
  } | null;
23
24
  controls: {
24
25
  id: string;
@@ -30,6 +30,27 @@ export interface FsmState {
30
30
  /** Modal states (approval/picker) expose modal buttons in the UI and are
31
31
  * treated as "interesting" — the dashboard surfaces them distinctly. */
32
32
  modal?: boolean;
33
+ /**
34
+ * For a modal state, what KIND of modal it is — the semantic distinction the
35
+ * status field (always 'approval' for any modal, so the dashboard surfaces it)
36
+ * deliberately loses. The auto-approve worker uses this to decide whether it
37
+ * may answer the modal on the user's behalf:
38
+ *
39
+ * - 'approval' — a tool/command/trust consent prompt ("Allow Bash?",
40
+ * "Trust this folder?"). Auto-approve MAY fire (a background mesh worker
41
+ * should not stall on these).
42
+ * - 'picker' — a selection menu the user opened (/model, /mode, …). There
43
+ * is no "correct" answer to auto-pick; blindly selecting the first option
44
+ * silently changes the model/mode. Auto-approve must NOT fire — the user
45
+ * chooses.
46
+ * - 'confirm' — a non-consent yes/no the user must decide. Left to the user.
47
+ *
48
+ * Defaults to 'approval' for a modal state that omits it (preserves the
49
+ * pre-existing "auto-approve any modal" behaviour for un-migrated specs;
50
+ * picker/confirm states declare their kind explicitly). Non-modal states
51
+ * have no modal_kind.
52
+ */
53
+ modal_kind?: 'approval' | 'picker' | 'confirm';
33
54
  /** Status this state maps to for the dashboard/cli-adapter status field.
34
55
  * One of: idle | generating | approval. Defaults: modal→approval,
35
56
  * initial→idle, id==='busy'→generating, else idle. Explicit wins. */
@@ -142,3 +163,12 @@ export declare function outgoingTransitions(spec: CliSpecV4, stateId: string): F
142
163
  /** Map a state to the dashboard status string, applying the documented
143
164
  * defaults when `status` is not explicit. */
144
165
  export declare function statusForState(state: FsmState): 'idle' | 'generating' | 'approval';
166
+ /**
167
+ * The modal kind for a state, or null when the state is not modal. A modal state
168
+ * that omits `modal_kind` defaults to 'approval' so the established
169
+ * auto-approve-any-modal behaviour is preserved for specs that have not yet
170
+ * declared a kind; picker/confirm states must opt out by declaring their kind.
171
+ * This is the value the cli-adapter carries on `activeModal.kind` and the
172
+ * auto-approve gate reads — see cli-provider-instance.maybeAutoApproveStatus.
173
+ */
174
+ export declare function modalKindForState(state: FsmState): 'approval' | 'picker' | 'confirm' | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.356",
3
+ "version": "0.9.82-rc.358",
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.356",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.358",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -14,6 +14,15 @@ export interface CliAdapterStatus {
14
14
  activeModal?: {
15
15
  message: string;
16
16
  buttons: string[];
17
+ /**
18
+ * Semantic modal class, when the adapter knows it (spec/FSM path):
19
+ * 'approval' = tool/command/trust consent (auto-approve may fire);
20
+ * 'picker' = a selection menu the user opened (/model, /mode — must NOT
21
+ * be auto-answered); 'confirm' = a yes/no left to the user. Absent/null
22
+ * for adapters that don't classify modals — the auto-approve gate then
23
+ * falls back to its structural heuristic.
24
+ */
25
+ kind?: 'approval' | 'picker' | 'confirm' | null;
17
26
  } | null;
18
27
  activeInteractivePrompt?: InteractivePrompt | null;
19
28
  providerSessionId?: string;
@@ -106,8 +106,51 @@ function sweepExpiredRemoteIdleSessions(): void {
106
106
 
107
107
  function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
108
108
  const localMesh = getMesh(meshId);
109
- if (localMesh) return localMesh;
110
- return components.router?.getCachedInlineMesh(meshId);
109
+ const cachedMesh = components.router?.getCachedInlineMesh(meshId);
110
+ if (!localMesh) return cachedMesh;
111
+ if (!cachedMesh) return localMesh;
112
+ return mergeInlineCacheOnlyNodes(localMesh, cachedMesh);
113
+ }
114
+
115
+ /**
116
+ * Claim-time membership view unification (CLAIMSTALL fix).
117
+ *
118
+ * The coordinator's claim path — triggerMeshQueue → autoLaunch candidate filter
119
+ * and the local/remote idle-session drain — reads mesh membership through
120
+ * getMeshWithCache, which historically returned the local-config mesh verbatim
121
+ * whenever one existed. A freshly cloned worktree node is registered ONLY into the
122
+ * router's inline mesh cache: clone_mesh_node's `meshRecord.inline` branch calls
123
+ * updateInlineMeshNode, NOT addNode, so the worktree node never reaches local
124
+ * config (meshes.json). The config-first view therefore omits the worktree node,
125
+ * while send_task — which resolves membership through getMeshForCommand(preferInline)
126
+ * over the same inline cache — sees it. That view asymmetry is the stall: a queue
127
+ * task pinned to the worktree node reports `target_node_id_unmatched` (autoLaunch
128
+ * candidate filter / targetPinUnmatched check) and the node's idle session is
129
+ * dropped from the drain pool (mesh.nodes.find miss), so claim never fires and the
130
+ * task is stranded pending — even though nodeId matching itself is correct.
131
+ *
132
+ * Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
133
+ * view matches the command (send_task) view. Base (non-worktree) nodes present in
134
+ * local config stay config-authoritative — their entry is taken verbatim from
135
+ * localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
136
+ * that exist solely in the inline cache (the cloned worktree nodes) are appended.
137
+ * Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
138
+ * identical to every other claim-path consumer — the matching logic is untouched,
139
+ * only which nodes are visible.
140
+ */
141
+ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
142
+ const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
143
+ const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
144
+ if (!cachedNodes.length) return localMesh;
145
+ const cacheOnly = cachedNodes.filter((cachedNode: any) => {
146
+ const cachedId = readMeshNodeId(cachedNode);
147
+ // Unidentifiable cache entries can never be a claim/route target — skip them
148
+ // rather than appending junk that no consumer can address.
149
+ if (!cachedId) return false;
150
+ return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
151
+ });
152
+ if (!cacheOnly.length) return localMesh;
153
+ return { ...localMesh, nodes: [...localNodes, ...cacheOnly] };
111
154
  }
112
155
 
113
156
  const INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS = 30 * 60 * 1000;
@@ -1797,7 +1840,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1797
1840
  // transcript and record the genuine completion once the worker truly finishes (commonly
1798
1841
  // after a coordinator nudge / re-dispatch). A matched queue task, or a completion with
1799
1842
  // genuine evidence, is marked terminal as before.
1800
- const leaveDirectDispatchActive = !task && opts?.tentativeIfDirect === true;
1843
+ // WARMUPGAP: a no-taskId completion from a session that holds no active assignment is a
1844
+ // pre-assignment warmup / ghost event (a worker spawns, idles, and emits idle→generating→
1845
+ // completed before any task is dispatched, with meshActiveTaskId unset so the event carries
1846
+ // no taskId). Letting it through would hit the session_id fallback in updateDirectDispatchStatus
1847
+ // and flip a sibling/stale dispatch row this event does not own — the real task later lands on
1848
+ // a corrupted row and never reaches completed. Skip the dispatch update for that case. A
1849
+ // taskId-carrying completion (real task), or any completion whose session currently holds an
1850
+ // active assignment (legacy/relayed worker), still flips as before.
1851
+ const leaveDirectDispatchActive = (!task && opts?.tentativeIfDirect === true)
1852
+ || (!eventTaskId && !sessionHasActiveAssignment(args.meshId, sessionId));
1801
1853
  if (!leaveDirectDispatchActive) {
1802
1854
  // CANON-B: flip the exact dispatch row the completion echoed its taskId for; the
1803
1855
  // session_id fallback (no echoed taskId) still covers legacy/relayed workers.
@@ -1915,7 +1967,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1915
1967
  // sibling must keep that row 'dispatched' so its own confirm can match it; acking
1916
1968
  // by session would mark it 'acked' prematurely and hide a genuine non-delivery.
1917
1969
  const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1918
- updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
1970
+ // WARMUPGAP: only ack a dispatch row when the event names its task, or the session
1971
+ // currently holds an active assignment. A no-taskId generating_started from an
1972
+ // unassigned session is a pre-assignment warmup — the session_id fallback would ack a
1973
+ // sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
1974
+ // hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
1975
+ // acks below are bound to actual deliveries and stay a no-op for a warmup session).
1976
+ if (startedTaskId || sessionHasActiveAssignment(args.meshId, sessionId)) {
1977
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
1978
+ }
1919
1979
  const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
1920
1980
  try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
1921
1981
  catch { return []; }
@@ -2143,6 +2203,88 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
2143
2203
  return { success: true, forwarded: 0 };
2144
2204
  }
2145
2205
 
2206
+ // Reconstruct the metadataEvent that injectMeshSystemMessage consumes from a forwarded
2207
+ // (cross-machine) mesh event. The remote relay hop arrives as a flat payload, NOT the
2208
+ // original provider event object, so this whitelists the fields the coordinator-side
2209
+ // pipeline reads and re-projects them. Kept pure + exported so the relay-path field
2210
+ // preservation (esp. taskId) is unit-testable without driving injectMeshSystemMessage.
2211
+ //
2212
+ // IMPORTANT asymmetry: the LOCAL in-process forward path (onMeshCoordinatorEventForwarded)
2213
+ // passes the whole event through as metadataEvent, so every field on the event survives
2214
+ // there for free. This remote-only path must explicitly mirror each field it needs.
2215
+ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown> {
2216
+ const relayModalMessage = readNonEmptyString(payload.modalMessage);
2217
+ const relayModalButtons = Array.isArray(payload.modalButtons)
2218
+ ? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
2219
+ : null;
2220
+ return {
2221
+ // Preserve the dispatch task id across the machine boundary. The `received` trace
2222
+ // stage reads payload.taskId; without mirroring it here the rebuilt metadataEvent
2223
+ // loses it, so injectMeshSystemMessage's traceCtx.taskId and the
2224
+ // updateDirectDispatchStatus(eventTaskId) call go undefined — the EvtTrace
2225
+ // queued/surfaced stages show task=- and the direct-dispatch ledger falls back to a
2226
+ // session_id match (which can flip a sibling row). The local in-process forward path
2227
+ // keeps event.taskId/meshActiveTaskId for free; this mirrors it for the remote relay.
2228
+ // Same taskId/meshActiveTaskId ordering the local unroutable trace uses.
2229
+ taskId: readNonEmptyString(payload.taskId) || readNonEmptyString(payload.meshActiveTaskId),
2230
+ targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
2231
+ providerType: readNonEmptyString(payload.providerType),
2232
+ providerSessionId: readNonEmptyString(payload.providerSessionId),
2233
+ // Preserve the originating coordinator SESSION id across the machine boundary so
2234
+ // the completion routes back to the exact coordinator session (multi-coordinator).
2235
+ // buildForwardPayloadFromPending spreads the worker event's metadata, so the id
2236
+ // arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
2237
+ // is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
2238
+ // anchors from this. Absent → daemon-level fallback (version-skew safe).
2239
+ meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
2240
+ // Carry the session identity fields the worker provider event emits so the
2241
+ // coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
2242
+ // settings. Without these the remote-relay hop reconstructs metadataEvent with
2243
+ // an empty workspace, and the dashboard flaps to the generic
2244
+ // "Terminal (Mesh Node)" title (and degrades the provider label) between live
2245
+ // events and the periodic get_status_metadata snapshot. The local in-process
2246
+ // forward path (onMeshCoordinatorEventForwarded) already preserves these; this
2247
+ // mirrors them for the remote-only relay path.
2248
+ workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
2249
+ workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
2250
+ sessionTitle: readNonEmptyString(payload.sessionTitle),
2251
+ sessionStatus: readNonEmptyString(payload.sessionStatus),
2252
+ sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
2253
+ providerName: readNonEmptyString(payload.providerName),
2254
+ ...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
2255
+ finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
2256
+ // T2: carry the worker's status-snapshot last-message preview across the machine
2257
+ // boundary so a summary-less completion still surfaces the assistant reply in the
2258
+ // coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
2259
+ // (assistant-role only) when finalSummary is absent.
2260
+ lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
2261
+ lastMessageRole: readNonEmptyString(payload.lastMessageRole),
2262
+ ...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
2263
+ jobId: readNonEmptyString(payload.jobId),
2264
+ interactionId: readNonEmptyString(payload.interactionId),
2265
+ status: readNonEmptyString(payload.status),
2266
+ targetDaemonId: readNonEmptyString(payload.targetDaemonId),
2267
+ startedAt: readNonEmptyString(payload.startedAt),
2268
+ completedAt: readNonEmptyString(payload.completedAt),
2269
+ retryOfJobId: readNonEmptyString(payload.retryOfJobId),
2270
+ ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
2271
+ ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
2272
+ ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
2273
+ ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
2274
+ ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
2275
+ ...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
2276
+ ...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
2277
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
2278
+ intentional: payload.intentional === true,
2279
+ intentionalStop: payload.intentionalStop === true,
2280
+ operatorCleanup: payload.operatorCleanup === true,
2281
+ reason: readNonEmptyString(payload.reason),
2282
+ stopReason: readNonEmptyString(payload.stopReason),
2283
+ cleanupReason: readNonEmptyString(payload.cleanupReason),
2284
+ source: readNonEmptyString(payload.source),
2285
+ };
2286
+ }
2287
+
2146
2288
  export function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>) {
2147
2289
  const eventName = readNonEmptyString(payload.event);
2148
2290
  if (!isMeshCoordinatorEvent(eventName)) {
@@ -2184,73 +2326,13 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
2184
2326
  event: eventName,
2185
2327
  });
2186
2328
  const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
2187
- const relayModalMessage = readNonEmptyString(payload.modalMessage);
2188
- const relayModalButtons = Array.isArray(payload.modalButtons)
2189
- ? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
2190
- : null;
2191
2329
 
2192
2330
  return injectMeshSystemMessage(components, {
2193
2331
  meshId,
2194
2332
  nodeId,
2195
2333
  nodeLabel,
2196
2334
  event: eventName,
2197
- metadataEvent: {
2198
- targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
2199
- providerType: readNonEmptyString(payload.providerType),
2200
- providerSessionId: readNonEmptyString(payload.providerSessionId),
2201
- // Preserve the originating coordinator SESSION id across the machine boundary so
2202
- // the completion routes back to the exact coordinator session (multi-coordinator).
2203
- // buildForwardPayloadFromPending spreads the worker event's metadata, so the id
2204
- // arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
2205
- // is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
2206
- // anchors from this. Absent → daemon-level fallback (version-skew safe).
2207
- meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
2208
- // Carry the session identity fields the worker provider event emits so the
2209
- // coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
2210
- // settings. Without these the remote-relay hop reconstructs metadataEvent with
2211
- // an empty workspace, and the dashboard flaps to the generic
2212
- // "Terminal (Mesh Node)" title (and degrades the provider label) between live
2213
- // events and the periodic get_status_metadata snapshot. The local in-process
2214
- // forward path (onMeshCoordinatorEventForwarded) already preserves these; this
2215
- // mirrors them for the remote-only relay path.
2216
- workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
2217
- workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
2218
- sessionTitle: readNonEmptyString(payload.sessionTitle),
2219
- sessionStatus: readNonEmptyString(payload.sessionStatus),
2220
- sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
2221
- providerName: readNonEmptyString(payload.providerName),
2222
- ...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
2223
- finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
2224
- // T2: carry the worker's status-snapshot last-message preview across the machine
2225
- // boundary so a summary-less completion still surfaces the assistant reply in the
2226
- // coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
2227
- // (assistant-role only) when finalSummary is absent.
2228
- lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
2229
- lastMessageRole: readNonEmptyString(payload.lastMessageRole),
2230
- ...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
2231
- jobId: readNonEmptyString(payload.jobId),
2232
- interactionId: readNonEmptyString(payload.interactionId),
2233
- status: readNonEmptyString(payload.status),
2234
- targetDaemonId: readNonEmptyString(payload.targetDaemonId),
2235
- startedAt: readNonEmptyString(payload.startedAt),
2236
- completedAt: readNonEmptyString(payload.completedAt),
2237
- retryOfJobId: readNonEmptyString(payload.retryOfJobId),
2238
- ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
2239
- ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
2240
- ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
2241
- ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
2242
- ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
2243
- ...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
2244
- ...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
2245
- ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
2246
- intentional: payload.intentional === true,
2247
- intentionalStop: payload.intentionalStop === true,
2248
- operatorCleanup: payload.operatorCleanup === true,
2249
- reason: readNonEmptyString(payload.reason),
2250
- stopReason: readNonEmptyString(payload.stopReason),
2251
- cleanupReason: readNonEmptyString(payload.cleanupReason),
2252
- source: readNonEmptyString(payload.source),
2253
- },
2335
+ metadataEvent: buildRelayMetadataEvent(payload),
2254
2336
  });
2255
2337
  }
2256
2338
 
@@ -24,7 +24,7 @@ import { LOG } from '../logging/logger.js';
24
24
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
25
25
  import type { ChatMessage } from '../types.js';
26
26
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
27
- import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
27
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
28
28
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
29
29
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
30
30
  import { normalizeProviderSessionId } from './provider-session-id.js';
@@ -1622,10 +1622,34 @@ export class CliProviderInstance implements ProviderInstance {
1622
1622
  if (!modal || buttons.length === 0) {
1623
1623
  return autoApproveActive;
1624
1624
  }
1625
- const { index: buttonIndex, label: buttonLabel } = pickAutoApprovalButton(buttons);
1626
- if (buttonIndex < 0) {
1627
- // No concrete button matched don't pick a random index, just
1628
- // surface the modal so the user can decide.
1625
+ // Picker/confirm exclusion (provider-common). A /model or /mode picker is
1626
+ // surfaced with status=waiting_approval so the dashboard shows it, but it
1627
+ // has no "correct" answer to auto-pick blindly selecting the first
1628
+ // option silently switches the model (the "always Opus, before I even
1629
+ // choose" bug). Two independent gates, BOTH must pass to fire:
1630
+ //
1631
+ // (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
1632
+ // not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
1633
+ // adapter, or a spec that predates modal_kind) reads as 'approval'
1634
+ // so genuine approvals keep auto-approving; only an explicit
1635
+ // 'picker'/'confirm' is excluded here.
1636
+ // (2) structural anchor — a real approval offers an affirmative AND a
1637
+ // decline option (pickApprovalButton finds a positive that isn't a
1638
+ // decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
1639
+ // is present). A model picker ("1. Default 2. Opus 3. Sonnet")
1640
+ // has no decline, so even an un-migrated picker is caught here.
1641
+ //
1642
+ // Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
1643
+ const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
1644
+ if (modalKind !== 'approval') {
1645
+ // Picker/confirm — leave it for the user; keep the modal surfaced.
1646
+ return autoApproveActive;
1647
+ }
1648
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
1649
+ if (buttonIndex < 0 || !hasNegativeApprovalOption(buttons)) {
1650
+ // No affirmative matched, or no decline option present (→ not a real
1651
+ // consent prompt, e.g. a picker that slipped past the kind gate).
1652
+ // Surface the modal so the user decides; never pick blindly.
1629
1653
  return autoApproveActive;
1630
1654
  }
1631
1655
  // Modal *identity* signature — the question/button set only, NO volatile
@@ -19,6 +19,7 @@
19
19
  'use strict';
20
20
 
21
21
  import { FsmDriver, type DashboardEvent, type ISpecDriver } from './fsm-driver.js';
22
+ import { lastContiguousNumberedBlock } from './evaluator.js';
22
23
  import { executeNativeHistory } from './native-history-executor.js';
23
24
  import * as fs from 'node:fs';
24
25
  import type { NativeHistoryConfig, Control, ControlAction } from './types.js';
@@ -75,7 +76,7 @@ export class SpecCliAdapter implements CliAdapter {
75
76
  };
76
77
  private lastEvent: DashboardEvent | null = null;
77
78
  private latestState: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' } | null = null;
78
- private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
79
+ private latestModal: { title: string | null; buttons: { index: number; label: string }[]; kind?: 'approval' | 'picker' | 'confirm' | null } | null = null;
79
80
  private statusCallback: (() => void) | null = null;
80
81
  private ptyDataCallback: ((data: string) => void) | null = null;
81
82
  private partialResponse = '';
@@ -193,8 +194,10 @@ export class SpecCliAdapter implements CliAdapter {
193
194
  messages: [],
194
195
  // Surface buttons when we have them; an approval state with no parsed
195
196
  // modal this frame still stays waiting_approval (no activeModal yet).
197
+ // `kind` carries the semantic modal class through to the auto-approve
198
+ // gate so a /model picker (kind='picker') is never auto-answered.
196
199
  activeModal: modal
197
- ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label) }
200
+ ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label), kind: modal.kind ?? null }
198
201
  : null,
199
202
  activeInteractivePrompt: this.activeInteractivePrompt,
200
203
  ...sessionFields,
@@ -519,21 +522,27 @@ export class SpecCliAdapter implements CliAdapter {
519
522
  const ec = action.extract_choices;
520
523
  if (!ec?.pattern) return [];
521
524
  const text = this.readScreenSectionText(ec.section);
522
- const out: Array<{ index: number; label: string; current: boolean }> = [];
523
- const seen = new Set<number>();
525
+ // Collect EVERY matching line in screen order with no top-down de-dup.
526
+ // The picker section can include conversation history above it (a stray
527
+ // "1./2./3." list, blockquote `>` lines); a `seen.has(idx)` first-wins
528
+ // scan would let those body lines claim the option indices and shadow
529
+ // the real choices — committing the wrong model under arrow-key nav.
530
+ const all: Array<{ index: number; label: string; current: boolean }> = [];
524
531
  for (const rawLine of text.split('\n')) {
525
532
  const line = rawLine.replace(/\r$/, '');
526
533
  const m = new RegExp(ec.pattern, ec.flags ?? '').exec(line);
527
534
  if (!m) continue;
528
535
  const idx = Number(m[1]);
529
- if (!Number.isFinite(idx) || seen.has(idx)) continue;
536
+ if (!Number.isFinite(idx) || idx <= 0) continue;
530
537
  const label = (m[2] ?? '').replace(/\s+/g, ' ').trim();
531
538
  if (!label) continue;
532
539
  const current = /^\s*[❯›>]/.test(line) || /[✔✓●]\s*$/.test(label);
533
- seen.add(idx);
534
- out.push({ index: idx, label, current });
540
+ all.push({ index: idx, label, current });
535
541
  }
536
- return out;
542
+ // Real options are the bottom-most contiguous numbered block; this also
543
+ // confines the `current` cursor flag to that block so a body `>` line is
544
+ // never mistaken for the cursor row.
545
+ return lastContiguousNumberedBlock(all);
537
546
  }
538
547
 
539
548
  /** Live text of a named screen section (or the whole screen when no
@@ -338,7 +338,10 @@ export function extractButtonsFromRule(
338
338
  label += ' ' + next.trim();
339
339
  j += 1;
340
340
  }
341
- if (buttons.some(b => b.index === idx)) continue;
341
+ // No top-down de-dup here: a stray body "1." above the modal would
342
+ // otherwise claim the index and shadow the real choice. Collect
343
+ // every match in screen order and let lastContiguousNumberedBlock
344
+ // pick the bottom-most option block below.
342
345
  const key = keyTemplate.replace(/\{index\}/g, String(idx));
343
346
  buttons.push({ index: idx, label, key, current });
344
347
  i = j - 1;
@@ -350,17 +353,51 @@ export function extractButtonsFromRule(
350
353
  const idx = Number(m[1]);
351
354
  const label = String(m[2] ?? '').trim();
352
355
  if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
353
- if (buttons.some(b => b.index === idx)) continue;
354
356
  const key = keyTemplate.replace(/\{index\}/g, String(idx));
355
357
  // The matched text begins at the cursor marker (the pattern's
356
358
  // optional `[❯›>]` prefix); flag this row as the cursor's current
357
359
  // position so `select_mode: 'arrow_keys'` can step from it.
360
+ // Like the continuation path, no top-down de-dup — body numbered
361
+ // lines are filtered out by the bottom-block selection below.
358
362
  buttons.push({ index: idx, label, key, current: hasCursorMarker(m[0]) });
359
363
  }
360
364
  }
361
365
 
362
- buttons.sort((a, b) => a.index - b.index);
363
- return buttons;
366
+ // Buttons were collected in screen (top→bottom) order. The real choices are
367
+ // the bottom-most contiguous numbered block (the modal/picker always renders
368
+ // them last); reduce to that block so conversation-history numbered lists
369
+ // pulled into the modal section can never be mistaken for options.
370
+ const block = lastContiguousNumberedBlock(buttons);
371
+ block.sort((a, b) => a.index - b.index);
372
+ return block;
373
+ }
374
+
375
+ /**
376
+ * Reduce a top→bottom-ordered list of parsed numbered entries to only the
377
+ * bottom-most contiguous block — the run whose indices descend by exactly 1
378
+ * scanning upward from the last entry.
379
+ *
380
+ * Picker and approval choices always render as the LAST contiguous numbered
381
+ * block at the bottom of the modal section. The conversation history above can
382
+ * carry its own stray "1./2./3." numbered lists (and blockquote `>` lines), and
383
+ * because section anchoring can pull body lines into the modal section, a naive
384
+ * top-down scan would bind the low option indices to those body lines and drop
385
+ * the real choices (the dashboard would show, and an arrow-key picker would
386
+ * commit, the wrong row). Selecting the bottom block makes the on-screen picker
387
+ * win regardless of body content. Since pickers number their options 1..N
388
+ * contiguously and bodies use indices ≥ 1, the upward chain always breaks the
389
+ * moment it would need a "0." above the picker's "1." — so the picker block is
390
+ * isolated cleanly. Entries are assumed already in screen order; the returned
391
+ * slice keeps that order.
392
+ */
393
+ export function lastContiguousNumberedBlock<T extends { index: number }>(entries: T[]): T[] {
394
+ if (entries.length <= 1) return entries.slice();
395
+ let start = entries.length - 1;
396
+ for (let i = entries.length - 1; i > 0; i -= 1) {
397
+ if (entries[i - 1].index === entries[i].index - 1) start = i - 1;
398
+ else break;
399
+ }
400
+ return entries.slice(start);
364
401
  }
365
402
 
366
403
  /** True when a button line carries a TUI cursor marker (`❯`, `›`, `>`) before
@@ -32,7 +32,7 @@ import {
32
32
  import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
33
  import {
34
34
  type CliSpecV4, type FsmState, type FsmTransition,
35
- initialState, stateById, statusForState, outgoingTransitions,
35
+ initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
36
36
  } from './fsm-types.js';
37
37
  import { loadFsmSpec } from './fsm-loader.js';
38
38
  import { applyPreLaunchTrust } from './pre-launch-trust.js';
@@ -44,7 +44,7 @@ import { LOG } from '../../logging/logger.js';
44
44
  export type DashboardEvent =
45
45
  | { kind: 'pty_data'; chunk: string }
46
46
  | { kind: 'state_changed'; state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
47
- modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
47
+ modal: { title: string | null; buttons: { index: number; label: string }[]; kind: 'approval' | 'picker' | 'confirm' | null } | null;
48
48
  controls: { id: string; label: string; action_type: string }[] }
49
49
  | { kind: 'notification'; id: string; title: string; body: string }
50
50
  | { kind: 'delegate'; id: string; task: string }
@@ -694,7 +694,12 @@ export class FsmDriver implements ISpecDriver {
694
694
  this.emit({
695
695
  kind: 'state_changed',
696
696
  state: next.state,
697
- modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
697
+ // kind is the SEMANTIC modal class (approval vs picker/confirm)
698
+ // derived from the FSM state, NOT from the parsed buttons — the
699
+ // status field already collapsed it to 'approval' so the modal is
700
+ // surfaced. The auto-approve worker needs the distinction back to
701
+ // avoid answering a /model picker on the user's behalf.
702
+ modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })), kind: modalKindForState(state) } : null,
698
703
  controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
699
704
  });
700
705
  this.fireNotifications(state.id, title);
@@ -88,6 +88,27 @@ export interface FsmState {
88
88
  /** Modal states (approval/picker) expose modal buttons in the UI and are
89
89
  * treated as "interesting" — the dashboard surfaces them distinctly. */
90
90
  modal?: boolean;
91
+ /**
92
+ * For a modal state, what KIND of modal it is — the semantic distinction the
93
+ * status field (always 'approval' for any modal, so the dashboard surfaces it)
94
+ * deliberately loses. The auto-approve worker uses this to decide whether it
95
+ * may answer the modal on the user's behalf:
96
+ *
97
+ * - 'approval' — a tool/command/trust consent prompt ("Allow Bash?",
98
+ * "Trust this folder?"). Auto-approve MAY fire (a background mesh worker
99
+ * should not stall on these).
100
+ * - 'picker' — a selection menu the user opened (/model, /mode, …). There
101
+ * is no "correct" answer to auto-pick; blindly selecting the first option
102
+ * silently changes the model/mode. Auto-approve must NOT fire — the user
103
+ * chooses.
104
+ * - 'confirm' — a non-consent yes/no the user must decide. Left to the user.
105
+ *
106
+ * Defaults to 'approval' for a modal state that omits it (preserves the
107
+ * pre-existing "auto-approve any modal" behaviour for un-migrated specs;
108
+ * picker/confirm states declare their kind explicitly). Non-modal states
109
+ * have no modal_kind.
110
+ */
111
+ modal_kind?: 'approval' | 'picker' | 'confirm';
91
112
  /** Status this state maps to for the dashboard/cli-adapter status field.
92
113
  * One of: idle | generating | approval. Defaults: modal→approval,
93
114
  * initial→idle, id==='busy'→generating, else idle. Explicit wins. */
@@ -236,3 +257,17 @@ export function statusForState(state: FsmState): 'idle' | 'generating' | 'approv
236
257
  if (state.id === 'busy' || state.id === 'generating') return 'generating';
237
258
  return 'idle';
238
259
  }
260
+
261
+ /**
262
+ * The modal kind for a state, or null when the state is not modal. A modal state
263
+ * that omits `modal_kind` defaults to 'approval' so the established
264
+ * auto-approve-any-modal behaviour is preserved for specs that have not yet
265
+ * declared a kind; picker/confirm states must opt out by declaring their kind.
266
+ * This is the value the cli-adapter carries on `activeModal.kind` and the
267
+ * auto-approve gate reads — see cli-provider-instance.maybeAutoApproveStatus.
268
+ */
269
+ export function modalKindForState(state: FsmState): 'approval' | 'picker' | 'confirm' | null {
270
+ if (state.modal_kind) return state.modal_kind;
271
+ if (state.modal) return 'approval';
272
+ return null;
273
+ }