@adhdev/daemon-core 1.0.18-rc.10 → 1.0.18-rc.11

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.
Files changed (33) hide show
  1. package/dist/index.js +690 -35
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +690 -35
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/mesh/mesh-active-work.d.ts +1 -1
  6. package/dist/mesh/mesh-ledger.d.ts +1 -1
  7. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  8. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  9. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  10. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  11. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  12. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  13. package/package.json +3 -3
  14. package/src/commands/high-family/mesh-events.ts +13 -2
  15. package/src/commands/med-family/mesh-queue.ts +74 -1
  16. package/src/commands/router-refine.ts +71 -4
  17. package/src/commands/router.ts +8 -0
  18. package/src/mesh/coordinator-prompt.ts +2 -1
  19. package/src/mesh/mesh-active-work.ts +11 -1
  20. package/src/mesh/mesh-event-classify.ts +19 -0
  21. package/src/mesh/mesh-event-forwarding.ts +46 -4
  22. package/src/mesh/mesh-events-utils.ts +42 -0
  23. package/src/mesh/mesh-ledger.ts +5 -0
  24. package/src/mesh/mesh-node-identity.ts +49 -0
  25. package/src/mesh/mesh-queue-assignment.ts +18 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +6 -1
  27. package/src/mesh/mesh-refine-gates.ts +300 -0
  28. package/src/mesh/mesh-work-queue.ts +85 -2
  29. package/src/providers/cli-provider-instance-types.ts +28 -0
  30. package/src/providers/cli-provider-instance.ts +90 -15
  31. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  32. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  33. package/src/providers/types/interactive-prompt.ts +77 -0
@@ -1231,6 +1231,19 @@ function propagateDependencyFailure(meshId: string, failedTaskId: string): MeshW
1231
1231
 
1232
1232
  const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
1233
1233
 
1234
+ /**
1235
+ * CANCEL-STICKY-TERMINAL: the terminal task statuses. A row in one of these states is a
1236
+ * historical record — no live dispatch owns it — and must NEVER be flipped back to an
1237
+ * active (`pending`/`assigned`) state by a late writer. The canonical live example is a
1238
+ * cancel that races the dispatch-failure `.catch` (mesh-queue-assignment.ts): that catch
1239
+ * fires-and-forgets an unconditional `updateTaskStatus(...,'pending')`, resolving AFTER
1240
+ * the cancel commits, which resurrected the cancelled row → it got re-claimed and the
1241
+ * reclaim watchdog re-drove the same prompt. Guarding the write side (see
1242
+ * {@link updateTaskStatus}) applies the same terminal-row protection
1243
+ * {@link reclaimStrandedAssignedTask} already enforces to EVERY status writer at once.
1244
+ */
1245
+ const TERMINAL_TASK_STATUSES = new Set<MeshTaskStatus>(['completed', 'failed', 'cancelled']);
1246
+
1234
1247
  /**
1235
1248
  * G3 (step ①) — fire-and-forget mission_close_candidate detection for the missions of
1236
1249
  * the given task ids. Called after any task-status mutation (completion / failure /
@@ -1267,12 +1280,33 @@ export function updateTaskStatus(
1267
1280
  meshId: string,
1268
1281
  taskId: string,
1269
1282
  status: MeshTaskStatus,
1270
- opts?: MeshQueueMutationOptions,
1283
+ opts?: {
1284
+ /**
1285
+ * CANCEL-STICKY-TERMINAL: operator/system override to permit a terminal→non-terminal
1286
+ * transition (e.g. an explicit operator reopen). Without this, a write that would flip
1287
+ * a `completed`/`failed`/`cancelled` row back to `pending`/`assigned` is refused as a
1288
+ * no-op. Terminal→terminal and any transition FROM a non-terminal state are unaffected.
1289
+ */
1290
+ force?: boolean;
1291
+ } & MeshQueueMutationOptions,
1271
1292
  ): MeshWorkQueueEntry | null {
1272
1293
  requireMeshHostQueueOwner(opts);
1273
1294
  const result = withQueueLock(meshId, () => {
1274
1295
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1275
1296
  if (!entry) return null;
1297
+ // CANCEL-STICKY-TERMINAL: never resurrect a terminal row into an active state. A late
1298
+ // fire-and-forget writer (canonically the dispatch-failure `.catch` requeue to
1299
+ // 'pending' in mesh-queue-assignment.ts, which resolves AFTER a cancel commits) must
1300
+ // not undo a cancel/completion/failure — that revival let the row be re-claimed and the
1301
+ // reclaim watchdog re-drive the same prompt. Refuse the transition as a no-op unless an
1302
+ // explicit operator override is passed. This is the write-side sibling of the
1303
+ // status!=='assigned' guard reclaimStrandedAssignedTask already applies.
1304
+ if (!opts?.force
1305
+ && TERMINAL_TASK_STATUSES.has(entry.status)
1306
+ && !TERMINAL_TASK_STATUSES.has(status)) {
1307
+ LOG.debug('MeshQueue', `Refusing updateTaskStatus(${taskId} → ${status}) on mesh ${meshId}: row is terminal (${entry.status}). A late writer (e.g. dispatch-failure requeue) must not resurrect a cancelled/completed/failed task. Pass force to override.`);
1308
+ return { entry, cascaded: [] as MeshWorkQueueEntry[] };
1309
+ }
1276
1310
  entry.status = status;
1277
1311
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1278
1312
  // Any transition OFF `assigned` ends the single-flight dispatch window (terminal
@@ -1313,18 +1347,67 @@ export function cancelTask(
1313
1347
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1314
1348
  if (!entry) return null;
1315
1349
  const now = new Date().toISOString();
1350
+ // CANCEL-STICKY-TERMINAL (authoritative cancel): capture the prior assignment BEFORE
1351
+ // clearing it, so the caller can stop the bound live worker. Leaving assignedNodeId/
1352
+ // SessionId/ProviderType on the cancelled row let the still-running worker keep emitting
1353
+ // delivery/turn signals that re-ignited the reclaim watchdog (observed: nonce 6→9,
1354
+ // needing two cancels + a manual session stop). Clearing them also drops this row from
1355
+ // the status==='assigned' counters so it can never be treated as live again.
1356
+ const priorAssignment: CancelledTaskAssignment | undefined = entry.assignedSessionId
1357
+ ? {
1358
+ sessionId: entry.assignedSessionId,
1359
+ nodeId: entry.assignedNodeId,
1360
+ providerType: entry.assignedProviderType,
1361
+ }
1362
+ : undefined;
1316
1363
  entry.status = 'cancelled';
1317
1364
  entry.cancelledAt = now;
1318
1365
  if (opts?.reason) entry.cancelReason = opts.reason;
1366
+ delete entry.assignedNodeId;
1367
+ delete entry.assignedSessionId;
1368
+ delete entry.assignedProviderType;
1369
+ delete entry.dispatchTimestamp;
1370
+ // Belt-and-suspenders: bump the dispatch nonce so any in-flight inject the
1371
+ // now-orphaned worker later echoes carries a stale nonce and is rejected by the
1372
+ // coordinator's stale-nonce guard — same mechanism reclaimStrandedAssignedTask uses.
1373
+ entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
1319
1374
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1320
1375
  endTaskDispatchInFlight(meshId, taskId);
1321
1376
  const cascaded = propagateDependencyFailure(meshId, taskId);
1322
- return { entry, cascaded };
1377
+ return { entry, cascaded, priorAssignment };
1323
1378
  });
1324
1379
  if (result) scheduleMissionCloseCandidateCheck(meshId, [result.entry, ...result.cascaded]);
1380
+ // Surface the prior binding to the caller (out-of-band from the persisted row, so it is
1381
+ // never serialized) so the cancel command handler — which holds DaemonComponents — can
1382
+ // stop the now-orphaned worker via the transport-aware stopStaleMeshWorker helper.
1383
+ if (result?.priorAssignment) lastCancelledTaskAssignment.set(`${meshId}::${taskId}`, result.priorAssignment);
1325
1384
  return result ? result.entry : null;
1326
1385
  }
1327
1386
 
1387
+ /**
1388
+ * CANCEL-STICKY-TERMINAL: the assignment a task carried at cancel time, handed to the cancel
1389
+ * command handler so it can stop the bound worker. cancelTask runs in the pure queue-store
1390
+ * module (no DaemonComponents), so it records the binding here and the handler drains it.
1391
+ */
1392
+ export interface CancelledTaskAssignment {
1393
+ sessionId: string;
1394
+ nodeId?: string;
1395
+ providerType?: string;
1396
+ }
1397
+
1398
+ const lastCancelledTaskAssignment = new Map<string, CancelledTaskAssignment>();
1399
+
1400
+ /**
1401
+ * Read-and-clear the assignment a just-cancelled task was bound to. Returns undefined when the
1402
+ * cancelled task had no live assignment (nothing to stop). One-shot: the entry is deleted on read.
1403
+ */
1404
+ export function takeCancelledTaskAssignment(meshId: string, taskId: string): CancelledTaskAssignment | undefined {
1405
+ const key = `${meshId}::${taskId}`;
1406
+ const value = lastCancelledTaskAssignment.get(key);
1407
+ if (value) lastCancelledTaskAssignment.delete(key);
1408
+ return value;
1409
+ }
1410
+
1328
1411
  /**
1329
1412
  * Return a queue task to pending for retry. By default, dead session targeting
1330
1413
  * and assigned ownership are cleared so stale assignments do not strand again.
@@ -66,6 +66,15 @@ export type CompletedFinalizationBlock = {
66
66
  // preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
67
67
  // worker's next approval turn resumes. Independent of valley length.
68
68
  holdForTranscript?: boolean;
69
+ // (CANON-C EARLY-EMIT FLOOR) Set on a missing_final_assistant block whose provider has NO
70
+ // external transcript source to trail — a PTY-parsed provider (codex-cli) or one that merely
71
+ // requires a final assistant before idle. For those the CANON-C decoupled-immediate emit is a
72
+ // pure timing guess (no separate transcript will land to upgrade it), so it must observe the
73
+ // CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS floor rather than fire at the ~13s first-poll
74
+ // waitedMs. A native-source block (claude-cli external-native, where the transcript legitimately
75
+ // trails the idle transition by a write) deliberately does NOT set this — its immediate emit is
76
+ // correct and is upgraded by the transcript reconcile.
77
+ noExternalTranscriptSource?: boolean;
69
78
  };
70
79
 
71
80
  export type CompletionFinalAssistantEvidence = {
@@ -87,6 +96,25 @@ export type ExternalTranscriptProbe = {
87
96
 
88
97
  export const COMPLETED_FINALIZATION_RETRY_MS = 1000;
89
98
  export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
99
+ // (CANON-C EARLY-EMIT FLOOR) Minimum elapsed time before the CANON-C transcript-evidence
100
+ // gate (allowTimeout blocks) may fire its decoupled-immediate completion for a block that
101
+ // has NO final-assistant evidence (missing_final_assistant, finalAssistantPresent=false).
102
+ //
103
+ // CANON-C deliberately decouples the idle NOTIFICATION from the transcript's final-assistant
104
+ // turn: for a native-source worker whose transcript write merely trails the idle transition,
105
+ // emitting immediately (weak, later upgraded) is correct. But the SAME allowTimeout path also
106
+ // carries codex/antigravity PLAIN terminal blocks whose on-screen "final" assistant never
107
+ // landed — there the immediate emit fires at whatever tiny waitedMs the first completion-gate
108
+ // poll observed (~13s live), stamping evidenceLevel=insufficient and racing the transcript
109
+ // before it can catch up or the 180s mesh-worker stall watchdog can arm. Requiring a minimum
110
+ // dwell gives the transcript a window to land (clearing the block for a GENUINE emit) and lets
111
+ // the stall watchdog own long turns, while still emitting a weak completion promptly once the
112
+ // floor is met so a genuinely-answerless turn is never wedged. A block WITH final-assistant
113
+ // evidence present is unaffected (it takes the normal emit path, not this floor). Scoped (at
114
+ // the call site) to the missing_final_assistant transcript-evidence gate on mesh workers.
115
+ // Sized so a short transcript-write lag resolves under it while staying well below the 30s
116
+ // COMPLETED_FINALIZATION_MAX_WAIT_MS cap that force-releases the weak completion regardless.
117
+ export const CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS = 20_000;
90
118
  // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
91
119
  // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
92
120
  // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
@@ -12,7 +12,7 @@ import * as fs from 'fs';
12
12
  import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope } from './contracts.js';
13
13
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
14
14
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
15
- import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
15
+ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, resolveInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
16
16
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
17
17
  import { shortHash } from '../system/hash.js';
18
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
@@ -56,6 +56,7 @@ import {
56
56
  STATUS_HYDRATION_TAIL_LIMIT,
57
57
  COMPLETED_FINALIZATION_RETRY_MS,
58
58
  COMPLETED_FINALIZATION_MAX_WAIT_MS,
59
+ CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS,
59
60
  NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
60
61
  PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
61
62
  ANTIGRAVITY_HOLD_QUIET_DWELL_MS,
@@ -376,10 +377,12 @@ export class CliProviderInstance implements ProviderInstance {
376
377
  // `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
377
378
  // so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
378
379
  // agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
379
- // app is backgrounded. We emit one agent:waiting_approval on ENTRY into the prompt
380
- // state (reusing the existing server push path, no cloud change) and dedupe on this
381
- // key so repeated status ticks with the same prompt do not re-fire. '' means no
382
- // prompt is currently active (cleared when the prompt is answered/gone).
380
+ // app is backgrounded. We emit one agent:waiting_choice on ENTRY into the prompt
381
+ // state (its own coordinator event, NOT the approval channel a multi-choice
382
+ // question is answered with mesh_answer_question, never mesh_approve) carrying the
383
+ // FULL InteractivePrompt payload, and dedupe on this key so repeated status ticks
384
+ // with the same prompt do not re-fire. '' means no prompt is currently active
385
+ // (cleared when the prompt is answered/gone).
383
386
  private lastInteractivePromptEventKey = '';
384
387
  private autoApproveBusy = false;
385
388
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
@@ -1272,7 +1275,16 @@ export class CliProviderInstance implements ProviderInstance {
1272
1275
  }
1273
1276
  } else if (event === 'interactive_prompt_response' && data) {
1274
1277
  try {
1275
- const response = normalizeInteractivePromptResponse(data);
1278
+ // mesh_answer_question (mission f1d25e11) sends a coordinator-friendly answer
1279
+ // form (per-question select by label/index) that must be resolved against the
1280
+ // AUTHORITATIVE active prompt held here. When the active prompt is present and
1281
+ // the promptId matches, resolve it; otherwise fall back to the strict keyed
1282
+ // form (dashboard local answers already send that shape).
1283
+ const response = (this.activeInteractivePrompt
1284
+ && this.activeInteractivePrompt.promptId === (data as { promptId?: unknown })?.promptId
1285
+ && Array.isArray((data as { answers?: unknown })?.answers))
1286
+ ? resolveInteractivePromptResponse(this.activeInteractivePrompt, data)
1287
+ : normalizeInteractivePromptResponse(data);
1276
1288
  if (this.activeInteractivePrompt?.promptId === response.promptId) {
1277
1289
  this.activeInteractivePrompt = null;
1278
1290
  }
@@ -2084,6 +2096,11 @@ export class CliProviderInstance implements ProviderInstance {
2084
2096
  reason: 'missing_final_assistant',
2085
2097
  terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
2086
2098
  allowTimeout: allowMissingAssistantTimeout,
2099
+ // PTY-parsed provider (codex-cli): no external transcript trails the idle
2100
+ // transition, so the CANON-C decoupled emit must observe the min-elapsed floor
2101
+ // rather than fire at the first-poll waitedMs. (claude-cli's external-native
2102
+ // branch above is exempt — its transcript legitimately lands moments later.)
2103
+ noExternalTranscriptSource: true,
2087
2104
  };
2088
2105
  }
2089
2106
  }
@@ -2790,6 +2807,43 @@ export class CliProviderInstance implements ProviderInstance {
2790
2807
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2791
2808
  return;
2792
2809
  }
2810
+ // (CANON-C EARLY-EMIT FLOOR) The transcript-evidence gate would otherwise fire its
2811
+ // decoupled-immediate completion at ANY waitedMs — including the ~13s a codex PLAIN
2812
+ // terminal `missing_final_assistant` block reaches on the first completion-gate poll,
2813
+ // with NO final assistant ever observed. That stamps a weak evidenceLevel=insufficient
2814
+ // completion the coordinator marks reviewRecommended but does not auto-hold, racing the
2815
+ // transcript before it can land and pre-empting the 180s mesh-worker stall watchdog.
2816
+ // Impose a minimum dwell — but ONLY for the no-external-transcript case
2817
+ // (noExternalTranscriptSource): a PTY-parsed provider has no separate transcript that
2818
+ // will land to upgrade the weak emit, so firing immediately is a pure timing guess. A
2819
+ // native-source block (claude-cli external-native) is deliberately NOT floored: its
2820
+ // transcript legitimately trails the idle transition by a write and the CANON-C
2821
+ // immediate emit is correct (upgraded by the reconcile). Keep holding (re-probe each
2822
+ // retry — the block clears for a GENUINE emit the moment the assistant turn lands) until
2823
+ // either the floor is met or the 30s cap force-releases the weak completion below.
2824
+ // Scoped to mesh worker sessions (the only place allowTimeout is set), so interactive
2825
+ // completion timing is untouched.
2826
+ if (isTranscriptEvidenceGate
2827
+ && blockReason === 'missing_final_assistant'
2828
+ && block.noExternalTranscriptSource === true
2829
+ && waitedMs < CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS) {
2830
+ if (pending.loggedBlockReason !== 'canon_c_min_elapsed_floor') {
2831
+ LOG.info('CLI', `[${this.type}] holding CANON-C decoupled emit until min-elapsed floor (waitedMs=${waitedMs} floor=${CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS}); no final assistant yet (${blockReason})`);
2832
+ if (this.isMeshWorkerSession()) {
2833
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `canon_c_min_elapsed_floor waited=${waitedMs}ms`);
2834
+ }
2835
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2836
+ blockReason,
2837
+ latestVisibleStatus,
2838
+ terminal: block.terminal === true,
2839
+ canonCMinElapsedFloor: true,
2840
+ waitedMs,
2841
+ });
2842
+ pending.loggedBlockReason = 'canon_c_min_elapsed_floor';
2843
+ }
2844
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
2845
+ return;
2846
+ }
2793
2847
  // (ANTIGRAVITY-30S-CAP-PREMATURE) The 30s cap has been reached for an antigravity
2794
2848
  // `holdForTranscript` block. The cap releases on ELAPSED TIME, not proof-of-idle — but
2795
2849
  // antigravity is native-source (assistant answer in native-history) with a PTY-derived
@@ -3960,24 +4014,38 @@ export class CliProviderInstance implements ProviderInstance {
3960
4014
  this.lastStatus = newStatus;
3961
4015
  }
3962
4016
 
3963
- // INTERACTIVE-PROMPT-PUSH: fire a push-worthy notification when the session
3964
- // ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to the
3965
- // status-change block above: an AskUserQuestion prompt is surfaced only as a
4017
+ // INTERACTIVE-QUESTION-EMIT: fire a coordinator/push-worthy notification when the
4018
+ // session ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to
4019
+ // the status-change block above: an AskUserQuestion prompt is surfaced only as a
3966
4020
  // display-only `waiting_choice` overlay in getState() (mirrored here off
3967
4021
  // adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
3968
4022
  // the raw adapter status stays idle/generating — so none of the status-keyed
3969
4023
  // arms above ever emit an agent:* event for it and the owner gets no web-push.
3970
4024
  //
3971
- // We REUSE agent:waiting_approval (the question message as modalMessage, the
3972
- // choice labels as modalButtons) so the existing server push path fires with
3973
- // zero cloud change the semantics ("the agent needs your input") match.
4025
+ // MESH-QUESTION-ANSWER-PATH (mission f1d25e11): this used to REUSE
4026
+ // agent:waiting_approval (question as modalMessage, choice labels as modalButtons).
4027
+ // That mis-classified a multi-choice QUESTION as an approval the coordinator saw
4028
+ // a task_approval_needed ledger row and tried mesh_approve, which cannot answer a
4029
+ // question (it resolves a yes/no modal). We now emit a DISTINCT
4030
+ // agent:waiting_choice event carrying the FULL InteractivePrompt payload
4031
+ // (promptId + every question's header/question/multiSelect and each option's
4032
+ // label/description) so the coordinator can render the choices and answer with the
4033
+ // dedicated mesh_answer_question tool. The rich payload survives the cross-machine
4034
+ // relay (see buildRelayMetadataEvent) so a REMOTE worker's question reaches the
4035
+ // coordinator with its options intact.
4036
+ //
4037
+ // We keep modalMessage/modalButtons on the event too so the existing server
4038
+ // web-push path (which reads those two fields) still fires with no cloud change —
4039
+ // but the payload's `interactivePrompt` is the authoritative, structured signal.
3974
4040
  //
3975
4041
  // Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
3976
4042
  // and reset the key when the prompt clears so a fresh prompt re-fires and a later
3977
4043
  // real completion still flows through the idle arm normally. We do NOT emit when
3978
4044
  // the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
3979
- // that arm already emits agent:waiting_approval, and firing here too would double
3980
- // up on the same modal.
4045
+ // that arm already emits agent:waiting_approval. A question (waiting_choice) and a
4046
+ // real approval (waiting_approval) therefore NEVER both fire for the same tick —
4047
+ // the approval arm wins and this arm yields (owner requirement: the two are
4048
+ // mutually exclusive).
3981
4049
  const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
3982
4050
  if (interactivePrompt && newStatus !== 'waiting_approval') {
3983
4051
  const firstQuestion = interactivePrompt.questions?.[0];
@@ -3991,7 +4059,14 @@ export class CliProviderInstance implements ProviderInstance {
3991
4059
  : undefined;
3992
4060
  const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
3993
4061
  this.pushEvent({
3994
- event: 'agent:waiting_approval', chatTitle, timestamp: now,
4062
+ event: 'agent:waiting_choice', chatTitle, timestamp: now,
4063
+ // Structured, authoritative payload — the coordinator renders these and
4064
+ // answers via mesh_answer_question. Carried whole (all questions, all
4065
+ // options) so multi-question / multi-select prompts round-trip fully.
4066
+ interactivePrompt,
4067
+ promptId: interactivePrompt.promptId,
4068
+ multiSelect: firstQuestion?.multiSelect === true,
4069
+ // Push-notification-friendly projection (server push path reads these).
3995
4070
  modalMessage,
3996
4071
  modalButtons,
3997
4072
  });
@@ -210,10 +210,33 @@ function buttonBlockApprovalCue(spec: ModalSpec, text: string): boolean {
210
210
  return hasNegativeApprovalOption(labels);
211
211
  }
212
212
 
213
+ /**
214
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: an
215
+ * AskUserQuestion multi-choice picker is NOT an approval modal. Its option rows
216
+ * ("❯ 1. label") can otherwise satisfy the approval button cue and get
217
+ * mis-classified as `waiting_approval`, so the worker's question is surfaced to
218
+ * the coordinator as a task_approval_needed (→ mesh_approve, which cannot answer
219
+ * it). The picker carries a distinctive signature the approval FSM never does:
220
+ * the claude TUI select footer ("Enter to select … Esc to cancel") together with
221
+ * the freeform escape hatch ("Type something" / "Chat about this"). When both are
222
+ * present the screen is a question picker — surfaced separately as
223
+ * waiting_choice — so the approval matchers must yield. Mirrors the legacy
224
+ * looksLikeSelectionPicker guard (cli-provider-instance.ts) ported to SDK-v1.
225
+ */
226
+ export function isAskUserQuestionPickerSignature(text: string): boolean {
227
+ if (!text) return false;
228
+ const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
229
+ if (!hasSelectFooter) return false;
230
+ return /Type something\.?|Chat about this/i.test(text);
231
+ }
232
+
213
233
  function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
214
234
  // Status-level modal detection is cue-only — does the question appear at all?
215
235
  // Button extraction lives in buildParseApprovalFromTui.
216
236
  const text = input.screenText ?? '';
237
+ // A question picker (AskUserQuestion) is never an approval — bail before any
238
+ // approval cue can match its numbered option rows (mission f1d25e11).
239
+ if (isAskUserQuestionPickerSignature(text)) return false;
217
240
  const question = compile(spec.questionPattern, spec.questionFlags ?? 'i');
218
241
  if (question.test(text)) return true;
219
242
  for (const variant of spec.questionVariants ?? []) {
@@ -78,6 +78,22 @@ function compile(re: string, flags?: string): RegExp {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: mirror
83
+ * detect-status.isAskUserQuestionPickerSignature at the button-parse layer. A
84
+ * multi-choice AskUserQuestion picker is not an approval modal; its numbered
85
+ * option rows can otherwise be extracted as approval buttons and produce a
86
+ * spurious approval modal. When the picker signature (claude TUI select footer +
87
+ * "Type something"/"Chat about this" freeform escape hatch) is present, return
88
+ * null early so the screen is surfaced as waiting_choice, never approval.
89
+ */
90
+ function isAskUserQuestionPickerSignature(text: string): boolean {
91
+ if (!text) return false;
92
+ const hasSelectFooter = /Enter to select/i.test(text) && /Esc to cancel/i.test(text);
93
+ if (!hasSelectFooter) return false;
94
+ return /Type something\.?|Chat about this/i.test(text);
95
+ }
96
+
81
97
  function findQuestionLineIndex(
82
98
  spec: ModalTuiSpec,
83
99
  lines: string[],
@@ -268,6 +284,10 @@ export function buildParseApprovalFromTui(
268
284
  return function parseApproval(input: CliApprovalInput): CliApprovalModal | null {
269
285
  const rawText = input.screenText ?? input.buffer ?? '';
270
286
  if (!rawText) return null;
287
+ // An AskUserQuestion picker is not an approval — never extract its option
288
+ // rows as approval buttons (mission f1d25e11). Check the raw text before any
289
+ // visible-region scoping trims the footer that identifies the picker.
290
+ if (isAskUserQuestionPickerSignature(rawText)) return null;
271
291
  const text = visibleRegion ? applyVisibleRegion(visibleRegion, rawText) : rawText;
272
292
  const lines = text.split('\n');
273
293
  const question = findQuestionLineIndex(spec, lines);
@@ -117,6 +117,83 @@ export function normalizeInteractivePromptResponse(raw: unknown): InteractivePro
117
117
  return { promptId, answers };
118
118
  }
119
119
 
120
+ /**
121
+ * Resolve a coordinator-friendly answer form into the strict, questionId-keyed
122
+ * InteractivePromptResponse the TUI/answer machinery consumes (mission f1d25e11).
123
+ *
124
+ * mesh_answer_question lets the coordinator answer against the option LABELS or
125
+ * 1-based INDEXES it saw in the agent:waiting_choice event, without having to
126
+ * reconstruct the exact questionId → selectedLabels map. This resolves that
127
+ * ergonomic form against the AUTHORITATIVE active prompt (the daemon holds it),
128
+ * so index/label resolution and question ordering are correct by construction.
129
+ *
130
+ * Accepted `raw` shapes:
131
+ * - The strict form ({ promptId, answers: { [questionId]: { selectedLabels } } })
132
+ * — passed straight to normalizeInteractivePromptResponse (back-compat).
133
+ * - The friendly form ({ promptId, answers: [ { questionId?, select?, freeform? } ] })
134
+ * — entries map to questions by questionId, else by array position. `select`
135
+ * is a label (string) / 1-based index (number) / array of either.
136
+ */
137
+ export function resolveInteractivePromptResponse(
138
+ prompt: InteractivePrompt,
139
+ raw: unknown,
140
+ ): InteractivePromptResponse {
141
+ if (!raw || typeof raw !== 'object') throw new Error('Interactive prompt response must be an object');
142
+ const record = raw as Record<string, unknown>;
143
+ const promptId = readString(record.promptId);
144
+ if (!promptId) throw new Error('promptId must be a non-empty string');
145
+ if (promptId !== prompt.promptId) throw new Error('Interactive prompt response does not match active prompt');
146
+ // Strict (keyed-object) form → existing normalizer.
147
+ if (record.answers && typeof record.answers === 'object' && !Array.isArray(record.answers)) {
148
+ return normalizeInteractivePromptResponse(record);
149
+ }
150
+ if (!Array.isArray(record.answers)) throw new Error('answers must be an array or a questionId-keyed object');
151
+
152
+ const resolveOneLabel = (question: InteractiveQuestion, sel: unknown): string => {
153
+ if (typeof sel === 'number' && Number.isFinite(sel)) {
154
+ const idx = Math.trunc(sel) - 1; // 1-based
155
+ const option = question.options[idx];
156
+ if (!option) throw new Error(`Option index ${sel} out of range for ${question.questionId}`);
157
+ return option.label;
158
+ }
159
+ const label = readString(sel);
160
+ if (!label) throw new Error(`Empty selection for ${question.questionId}`);
161
+ // Exact label match first; fall back to a numeric string index.
162
+ const exact = question.options.find((o) => o.label === label);
163
+ if (exact) return exact.label;
164
+ const asIndex = Number(label);
165
+ if (Number.isInteger(asIndex)) {
166
+ const option = question.options[asIndex - 1];
167
+ if (option) return option.label;
168
+ }
169
+ throw new Error(`Unknown option for ${question.questionId}: ${label}`);
170
+ };
171
+
172
+ const answers: Record<string, InteractiveAnswer> = {};
173
+ const entries = record.answers as unknown[];
174
+ entries.forEach((entryRaw, index) => {
175
+ if (!entryRaw || typeof entryRaw !== 'object' || Array.isArray(entryRaw)) return;
176
+ const entry = entryRaw as Record<string, unknown>;
177
+ const questionId = readString(entry.questionId);
178
+ const question = (questionId ? prompt.questions.find((q) => q.questionId === questionId) : undefined)
179
+ ?? prompt.questions[index];
180
+ if (!question) throw new Error(`No matching question for answer entry ${index}`);
181
+ const freeformText = readString(entry.freeform) ?? readString(entry.freeformText);
182
+ const selectedLabels: string[] = [];
183
+ const select = entry.select;
184
+ if (Array.isArray(select)) {
185
+ for (const sel of select) selectedLabels.push(resolveOneLabel(question, sel));
186
+ } else if (select !== undefined && select !== null) {
187
+ selectedLabels.push(resolveOneLabel(question, select));
188
+ }
189
+ answers[question.questionId] = {
190
+ selectedLabels,
191
+ ...(freeformText ? { freeformText } : {}),
192
+ };
193
+ });
194
+ return { promptId, answers };
195
+ }
196
+
120
197
  export function buildClaudeInteractiveToolResult(response: InteractivePromptResponse): string {
121
198
  return JSON.stringify({
122
199
  type: 'user',