@adhdev/daemon-core 0.9.82-rc.355 → 0.9.82-rc.357

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;
@@ -121,6 +121,12 @@ export declare class SpecCliAdapter implements CliAdapter {
121
121
  * — not this code — decides how a selection is keyed for each CLI.
122
122
  */
123
123
  private selectPickerChoice;
124
+ /** Parse the picker choices only if the picker already appears rendered on
125
+ * the live screen (its `wait_for` condition currently matches and at least
126
+ * one choice parses). Returns the parsed choices when open, else null so
127
+ * the caller knows it must send the trigger to open it. Used to de-dup the
128
+ * picker open in {@link selectPickerChoice}. */
129
+ private extractPickerChoicesIfRendered;
124
130
  /** Poll the live screen until the picker's `wait_for` condition matches,
125
131
  * up to a short budget. Returns true if it rendered, false on timeout. */
126
132
  private waitForPickerRendered;
@@ -20,4 +20,5 @@ export declare function extractButtonsFromRule(rule: ExtractButtons, hay: string
20
20
  index: number;
21
21
  label: string;
22
22
  key: string;
23
+ current: boolean;
23
24
  }[];
@@ -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;
@@ -19,6 +19,24 @@ export type ControlAction = {
19
19
  wait_for: WaitForCondition;
20
20
  extract_choices: SectionPattern;
21
21
  submit_key: string;
22
+ /**
23
+ * How a parsed choice is committed once the picker is open.
24
+ * 'index' (default) — type the on-screen number then `submit_key`
25
+ * (`{index}\r`). Correct for CLIs whose picker is number-selectable
26
+ * (codex-cli, hermes-cli).
27
+ * 'arrow_keys' — the picker is a cursor list that ignores number keys
28
+ * (claude-cli /model): move the cursor from its current row to the
29
+ * target row with up/down arrows, then confirm with the `submit_key`
30
+ * tail (the `\r` left after stripping `{index}`). Requires the
31
+ * extracted choices to flag the current cursor row.
32
+ */
33
+ select_mode?: 'index' | 'arrow_keys';
34
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to
35
+ * ANSI cursor up/down (`` / ``) when omitted. */
36
+ cursor_keys?: {
37
+ up: string;
38
+ down: string;
39
+ };
22
40
  } | {
23
41
  type: 'attach_image';
24
42
  method: 'tempfile_then_keys';
@@ -181,4 +199,24 @@ export interface ExtractButtons {
181
199
  key_for_index: string;
182
200
  min_count?: number;
183
201
  continuation_lines?: boolean;
202
+ /**
203
+ * How a button is committed when its modal is resolved (auto-approve or an
204
+ * explicit dashboard click).
205
+ * 'index' (default) — send `key_for_index` with `{index}` filled in
206
+ * (`{index}\r` → `1\r`). Correct for modals whose buttons are
207
+ * number-selectable (codex, hermes, antigravity number rows).
208
+ * 'arrow_keys' — the modal is a cursor list that IGNORES number keys
209
+ * (claude-cli's new TUI approval modal): a typed `1` leaks into the
210
+ * composer as literal text and `\r` submits it. Instead move the cursor
211
+ * from its current row to the target row with up/down arrows, then
212
+ * confirm with the `key_for_index` tail (the `\r` left after stripping
213
+ * `{index}`). Mirrors the `open_picker` `select_mode` of the same name.
214
+ */
215
+ select_mode?: 'index' | 'arrow_keys';
216
+ /** Arrow byte sequences for `select_mode: 'arrow_keys'`. Defaults to ANSI
217
+ * cursor up/down (`[A` / `[B`) when omitted. */
218
+ cursor_keys?: {
219
+ up: string;
220
+ down: string;
221
+ };
184
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.355",
3
+ "version": "0.9.82-rc.357",
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.355",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.357",
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;
@@ -2143,6 +2186,88 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
2143
2186
  return { success: true, forwarded: 0 };
2144
2187
  }
2145
2188
 
2189
+ // Reconstruct the metadataEvent that injectMeshSystemMessage consumes from a forwarded
2190
+ // (cross-machine) mesh event. The remote relay hop arrives as a flat payload, NOT the
2191
+ // original provider event object, so this whitelists the fields the coordinator-side
2192
+ // pipeline reads and re-projects them. Kept pure + exported so the relay-path field
2193
+ // preservation (esp. taskId) is unit-testable without driving injectMeshSystemMessage.
2194
+ //
2195
+ // IMPORTANT asymmetry: the LOCAL in-process forward path (onMeshCoordinatorEventForwarded)
2196
+ // passes the whole event through as metadataEvent, so every field on the event survives
2197
+ // there for free. This remote-only path must explicitly mirror each field it needs.
2198
+ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown> {
2199
+ const relayModalMessage = readNonEmptyString(payload.modalMessage);
2200
+ const relayModalButtons = Array.isArray(payload.modalButtons)
2201
+ ? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
2202
+ : null;
2203
+ return {
2204
+ // Preserve the dispatch task id across the machine boundary. The `received` trace
2205
+ // stage reads payload.taskId; without mirroring it here the rebuilt metadataEvent
2206
+ // loses it, so injectMeshSystemMessage's traceCtx.taskId and the
2207
+ // updateDirectDispatchStatus(eventTaskId) call go undefined — the EvtTrace
2208
+ // queued/surfaced stages show task=- and the direct-dispatch ledger falls back to a
2209
+ // session_id match (which can flip a sibling row). The local in-process forward path
2210
+ // keeps event.taskId/meshActiveTaskId for free; this mirrors it for the remote relay.
2211
+ // Same taskId/meshActiveTaskId ordering the local unroutable trace uses.
2212
+ taskId: readNonEmptyString(payload.taskId) || readNonEmptyString(payload.meshActiveTaskId),
2213
+ targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
2214
+ providerType: readNonEmptyString(payload.providerType),
2215
+ providerSessionId: readNonEmptyString(payload.providerSessionId),
2216
+ // Preserve the originating coordinator SESSION id across the machine boundary so
2217
+ // the completion routes back to the exact coordinator session (multi-coordinator).
2218
+ // buildForwardPayloadFromPending spreads the worker event's metadata, so the id
2219
+ // arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
2220
+ // is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
2221
+ // anchors from this. Absent → daemon-level fallback (version-skew safe).
2222
+ meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
2223
+ // Carry the session identity fields the worker provider event emits so the
2224
+ // coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
2225
+ // settings. Without these the remote-relay hop reconstructs metadataEvent with
2226
+ // an empty workspace, and the dashboard flaps to the generic
2227
+ // "Terminal (Mesh Node)" title (and degrades the provider label) between live
2228
+ // events and the periodic get_status_metadata snapshot. The local in-process
2229
+ // forward path (onMeshCoordinatorEventForwarded) already preserves these; this
2230
+ // mirrors them for the remote-only relay path.
2231
+ workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
2232
+ workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
2233
+ sessionTitle: readNonEmptyString(payload.sessionTitle),
2234
+ sessionStatus: readNonEmptyString(payload.sessionStatus),
2235
+ sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
2236
+ providerName: readNonEmptyString(payload.providerName),
2237
+ ...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
2238
+ finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
2239
+ // T2: carry the worker's status-snapshot last-message preview across the machine
2240
+ // boundary so a summary-less completion still surfaces the assistant reply in the
2241
+ // coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
2242
+ // (assistant-role only) when finalSummary is absent.
2243
+ lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
2244
+ lastMessageRole: readNonEmptyString(payload.lastMessageRole),
2245
+ ...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
2246
+ jobId: readNonEmptyString(payload.jobId),
2247
+ interactionId: readNonEmptyString(payload.interactionId),
2248
+ status: readNonEmptyString(payload.status),
2249
+ targetDaemonId: readNonEmptyString(payload.targetDaemonId),
2250
+ startedAt: readNonEmptyString(payload.startedAt),
2251
+ completedAt: readNonEmptyString(payload.completedAt),
2252
+ retryOfJobId: readNonEmptyString(payload.retryOfJobId),
2253
+ ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
2254
+ ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
2255
+ ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
2256
+ ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
2257
+ ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
2258
+ ...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
2259
+ ...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
2260
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
2261
+ intentional: payload.intentional === true,
2262
+ intentionalStop: payload.intentionalStop === true,
2263
+ operatorCleanup: payload.operatorCleanup === true,
2264
+ reason: readNonEmptyString(payload.reason),
2265
+ stopReason: readNonEmptyString(payload.stopReason),
2266
+ cleanupReason: readNonEmptyString(payload.cleanupReason),
2267
+ source: readNonEmptyString(payload.source),
2268
+ };
2269
+ }
2270
+
2146
2271
  export function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>) {
2147
2272
  const eventName = readNonEmptyString(payload.event);
2148
2273
  if (!isMeshCoordinatorEvent(eventName)) {
@@ -2184,73 +2309,13 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
2184
2309
  event: eventName,
2185
2310
  });
2186
2311
  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
2312
 
2192
2313
  return injectMeshSystemMessage(components, {
2193
2314
  meshId,
2194
2315
  nodeId,
2195
2316
  nodeLabel,
2196
2317
  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
- },
2318
+ metadataEvent: buildRelayMetadataEvent(payload),
2254
2319
  });
2255
2320
  }
2256
2321
 
@@ -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
@@ -2034,6 +2058,23 @@ export class CliProviderInstance implements ProviderInstance {
2034
2058
  ? event.providerSessionId
2035
2059
  : this.providerSessionId,
2036
2060
  };
2061
+ // TASKIDLESS: stamp the mesh task primary key on lifecycle events emitted by
2062
+ // a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
2063
+ // to key on task_id (CANON-B), but the producer never carried it — so every
2064
+ // forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
2065
+ // to a session_id match, which can flip a sibling dispatch row. The session
2066
+ // already knows its own taskId via attachMeshAssignment (settings.meshActiveTaskId);
2067
+ // surface it here so updateDirectDispatchStatus hits the exact PK row and the
2068
+ // session_id fallback is never exercised. Non-mesh sessions get no taskId
2069
+ // (regression guard) — isMeshWorkerSession() gates the injection.
2070
+ if (this.isMeshWorkerSession() && this.settings.meshActiveTaskId) {
2071
+ const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
2072
+ ? enrichedEvent.taskId
2073
+ : undefined;
2074
+ if (!existingTaskId) {
2075
+ enrichedEvent.taskId = this.settings.meshActiveTaskId;
2076
+ }
2077
+ }
2037
2078
  if (this.context?.emitProviderEvent) {
2038
2079
  this.context.emitProviderEvent(enrichedEvent);
2039
2080
  } else {
@@ -75,7 +75,7 @@ export class SpecCliAdapter implements CliAdapter {
75
75
  };
76
76
  private lastEvent: DashboardEvent | null = null;
77
77
  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;
78
+ private latestModal: { title: string | null; buttons: { index: number; label: string }[]; kind?: 'approval' | 'picker' | 'confirm' | null } | null = null;
79
79
  private statusCallback: (() => void) | null = null;
80
80
  private ptyDataCallback: ((data: string) => void) | null = null;
81
81
  private partialResponse = '';
@@ -193,8 +193,10 @@ export class SpecCliAdapter implements CliAdapter {
193
193
  messages: [],
194
194
  // Surface buttons when we have them; an approval state with no parsed
195
195
  // modal this frame still stays waiting_approval (no activeModal yet).
196
+ // `kind` carries the semantic modal class through to the auto-approve
197
+ // gate so a /model picker (kind='picker') is never auto-answered.
196
198
  activeModal: modal
197
- ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label) }
199
+ ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label), kind: modal.kind ?? null }
198
200
  : null,
199
201
  activeInteractivePrompt: this.activeInteractivePrompt,
200
202
  ...sessionFields,
@@ -413,11 +415,18 @@ export class SpecCliAdapter implements CliAdapter {
413
415
  choiceLabel: string | undefined,
414
416
  ): Promise<unknown> {
415
417
  // Open + wait so the choice list is on screen before we resolve the
416
- // label → index mapping (a fresh invoke may arrive with the picker
417
- // closed; re-opening an open picker is a no-op on these CLIs).
418
- this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
419
- await this.waitForPickerRendered(action);
420
- const options = this.extractPickerChoices(action);
418
+ // label → index mapping. The picker is normally ALREADY open here (a
419
+ // preceding list invoke leaves it rendered), so only send the trigger
420
+ // when it is not on screen. Re-sending the trigger to an open picker is
421
+ // NOT a harmless no-op on claude-cli: the trailing CR of `/model\r`
422
+ // lands as Enter on the cursor's current row and commits the wrong
423
+ // model before we navigate. De-dup the open to avoid that.
424
+ let options = this.extractPickerChoicesIfRendered(action);
425
+ if (!options) {
426
+ this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
427
+ await this.waitForPickerRendered(action);
428
+ options = this.extractPickerChoices(action);
429
+ }
421
430
 
422
431
  let index = choiceIndex;
423
432
  if ((index == null || !Number.isFinite(index)) && choiceLabel) {
@@ -432,8 +441,34 @@ export class SpecCliAdapter implements CliAdapter {
432
441
  return { ok: false, error: 'choiceIndex or choiceLabel required to select' };
433
442
  }
434
443
 
435
- const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
436
- this.driver.dispatch({ kind: 'pty_write', data: keys });
444
+ if (action.select_mode === 'arrow_keys') {
445
+ // Cursor-list picker (claude-cli /model): number keys are ignored.
446
+ // The cursor starts on the active row (extract flags it `current`);
447
+ // step it to the target row with arrows, then confirm.
448
+ const current = options.find(o => o.current);
449
+ if (current == null) {
450
+ // Without a known cursor position a blind Enter would commit
451
+ // whatever row the cursor sits on — fail loud instead.
452
+ return {
453
+ ok: false,
454
+ error: 'arrow-nav picker: current cursor row not detected on screen',
455
+ controlResult: { options: options.map(o => ({ value: o.label, label: o.label, current: o.current })) },
456
+ };
457
+ }
458
+ const up = action.cursor_keys?.up ?? '';
459
+ const down = action.cursor_keys?.down ?? '';
460
+ const delta = index - current.index;
461
+ const step = delta >= 0 ? down : up;
462
+ const nav = step.repeat(Math.abs(delta));
463
+ // Confirm key = submit_key with the (unused) {index} placeholder
464
+ // stripped — e.g. `{index}\r` → `\r`.
465
+ const confirm = (action.submit_key || '\r').replace(/\{index\}/g, '') || '\r';
466
+ if (nav) this.driver.dispatch({ kind: 'pty_write', data: nav });
467
+ this.driver.dispatch({ kind: 'pty_write', data: confirm });
468
+ } else {
469
+ const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
470
+ this.driver.dispatch({ kind: 'pty_write', data: keys });
471
+ }
437
472
  const selected = options.find(o => o.index === index);
438
473
  return {
439
474
  ok: true,
@@ -446,6 +481,23 @@ export class SpecCliAdapter implements CliAdapter {
446
481
  };
447
482
  }
448
483
 
484
+ /** Parse the picker choices only if the picker already appears rendered on
485
+ * the live screen (its `wait_for` condition currently matches and at least
486
+ * one choice parses). Returns the parsed choices when open, else null so
487
+ * the caller knows it must send the trigger to open it. Used to de-dup the
488
+ * picker open in {@link selectPickerChoice}. */
489
+ private extractPickerChoicesIfRendered(
490
+ action: Extract<ControlAction, { type: 'open_picker' }>,
491
+ ): Array<{ index: number; label: string; current: boolean }> | null {
492
+ const wf = action.wait_for;
493
+ if (wf?.regex) {
494
+ const re = new RegExp(wf.regex, wf.flags ?? 'i');
495
+ if (!re.test(this.readScreenSectionText(wf.section))) return null;
496
+ }
497
+ const options = this.extractPickerChoices(action);
498
+ return options.length > 0 ? options : null;
499
+ }
500
+
449
501
  /** Poll the live screen until the picker's `wait_for` condition matches,
450
502
  * up to a short budget. Returns true if it rendered, false on timeout. */
451
503
  private async waitForPickerRendered(action: Extract<ControlAction, { type: 'open_picker' }>): Promise<boolean> {
@@ -314,10 +314,10 @@ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
314
314
  export function extractButtonsFromRule(
315
315
  rule: ExtractButtons,
316
316
  hay: string,
317
- ): { index: number; label: string; key: string }[] {
317
+ ): { index: number; label: string; key: string; current: boolean }[] {
318
318
  const keyTemplate = rule.key_for_index;
319
319
  const continuationLines = rule.continuation_lines ?? false;
320
- const buttons: { index: number; label: string; key: string }[] = [];
320
+ const buttons: { index: number; label: string; key: string; current: boolean }[] = [];
321
321
 
322
322
  if (continuationLines) {
323
323
  const re = compileLinePattern(rule);
@@ -328,6 +328,7 @@ export function extractButtonsFromRule(
328
328
  const idx = Number(m[1]);
329
329
  let label = String(m[2] ?? '').trim();
330
330
  if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
331
+ const current = hasCursorMarker(lines[i]);
331
332
  let j = i + 1;
332
333
  while (j < lines.length) {
333
334
  const next = lines[j];
@@ -339,7 +340,7 @@ export function extractButtonsFromRule(
339
340
  }
340
341
  if (buttons.some(b => b.index === idx)) continue;
341
342
  const key = keyTemplate.replace(/\{index\}/g, String(idx));
342
- buttons.push({ index: idx, label, key });
343
+ buttons.push({ index: idx, label, key, current });
343
344
  i = j - 1;
344
345
  }
345
346
  } else {
@@ -351,10 +352,19 @@ export function extractButtonsFromRule(
351
352
  if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
352
353
  if (buttons.some(b => b.index === idx)) continue;
353
354
  const key = keyTemplate.replace(/\{index\}/g, String(idx));
354
- buttons.push({ index: idx, label, key });
355
+ // The matched text begins at the cursor marker (the pattern's
356
+ // optional `[❯›>]` prefix); flag this row as the cursor's current
357
+ // position so `select_mode: 'arrow_keys'` can step from it.
358
+ buttons.push({ index: idx, label, key, current: hasCursorMarker(m[0]) });
355
359
  }
356
360
  }
357
361
 
358
362
  buttons.sort((a, b) => a.index - b.index);
359
363
  return buttons;
360
364
  }
365
+
366
+ /** True when a button line carries a TUI cursor marker (`❯`, `›`, `>`) before
367
+ * its number — i.e. the cursor currently sits on that row. */
368
+ function hasCursorMarker(text: string): boolean {
369
+ return /^\s*[❯›>]/.test(text);
370
+ }