@adhdev/daemon-core 1.0.18-rc.9 → 1.0.19

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 (67) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  2. package/dist/commands/cli-manager.d.ts +9 -0
  3. package/dist/commands/upgrade-helper.d.ts +1 -0
  4. package/dist/commands/windows-atomic-upgrade.d.ts +1 -0
  5. package/dist/config/mesh-json-config.d.ts +62 -0
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.js +2205 -657
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2173 -634
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-active-work.d.ts +1 -1
  12. package/dist/mesh/mesh-ledger.d.ts +1 -1
  13. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  14. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  15. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  16. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  17. package/dist/providers/auto-approve-modes.d.ts +14 -0
  18. package/dist/providers/chat-message-normalization.d.ts +22 -0
  19. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  20. package/dist/providers/cli-provider-instance.d.ts +76 -0
  21. package/dist/providers/contracts.d.ts +17 -0
  22. package/dist/providers/provider-schema.d.ts +1 -0
  23. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  24. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  25. package/dist/repo-mesh-types.d.ts +38 -6
  26. package/dist/shared-types.d.ts +4 -2
  27. package/package.json +3 -3
  28. package/src/cli-adapters/cli-state-engine.ts +17 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +2 -6
  30. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  31. package/src/commands/cli-manager.ts +72 -8
  32. package/src/commands/high-family/mesh-events.ts +13 -2
  33. package/src/commands/med-family/mesh-crud.ts +155 -0
  34. package/src/commands/med-family/mesh-queue.ts +74 -1
  35. package/src/commands/router-refine.ts +71 -4
  36. package/src/commands/router.ts +8 -0
  37. package/src/commands/upgrade-helper.ts +50 -1
  38. package/src/commands/windows-atomic-upgrade.ts +88 -23
  39. package/src/config/mesh-json-config.ts +103 -0
  40. package/src/index.ts +21 -1
  41. package/src/mesh/coordinator-prompt.ts +2 -1
  42. package/src/mesh/mesh-active-work.ts +11 -1
  43. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  44. package/src/mesh/mesh-event-classify.ts +19 -0
  45. package/src/mesh/mesh-event-forwarding.ts +64 -6
  46. package/src/mesh/mesh-events-utils.ts +42 -0
  47. package/src/mesh/mesh-ledger.ts +5 -0
  48. package/src/mesh/mesh-node-identity.ts +49 -0
  49. package/src/mesh/mesh-queue-assignment.ts +84 -10
  50. package/src/mesh/mesh-reconcile-loop.ts +257 -3
  51. package/src/mesh/mesh-refine-gates.ts +300 -0
  52. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  53. package/src/mesh/mesh-work-queue.ts +85 -2
  54. package/src/providers/auto-approve-modes.ts +97 -0
  55. package/src/providers/chat-message-normalization.ts +53 -0
  56. package/src/providers/cli-provider-instance-types.ts +28 -0
  57. package/src/providers/cli-provider-instance.ts +394 -28
  58. package/src/providers/contracts.ts +25 -1
  59. package/src/providers/provider-schema.ts +83 -0
  60. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  61. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  62. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  63. package/src/providers/types/interactive-prompt.ts +77 -0
  64. package/src/repo-mesh-types.ts +112 -12
  65. package/src/shared-types.ts +9 -1
  66. package/src/status/reporter.ts +1 -0
  67. package/src/status/snapshot.ts +2 -0
@@ -60,57 +60,78 @@ export async function pullRemoteNodeQueues(
60
60
  // block the other nodes for the rest of the tick. Each node callback is fully
61
61
  // self-contained (local/candidate skip, peer-connected pre-check, per-candidate
62
62
  // pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
63
- await Promise.allSettled(mesh.nodes.map(async (node) => {
64
- const nodeDaemonId = readNonEmptyString(node.daemonId);
65
- // Skip nodes without a daemon, and nodes on THIS daemon (their events are
66
- // already in the local queue drained in PHASE 2). "This daemon" is matched
67
- // against the full self-identity set (candidateDaemonIds), not just the bare
68
- // localDaemonId — a self node can be registered under the config-form daemonId
69
- // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
70
- // from ourselves over P2P is both wasteful and a self-dispatch hazard.
71
- if (!nodeDaemonId) return;
72
- if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
73
- if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
63
+ await Promise.allSettled(mesh.nodes.map(node =>
64
+ pullPendingEventsFromNode(components, meshId, node, localDaemonId, candidateDaemonIds, pulls)));
65
+ }
74
66
 
75
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
76
- // a degraded peer whose DataChannel is not open would sink this pull into
77
- // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
78
- // the whole serial loop and delaying completion-event recovery from healthy
79
- // nodes. Skip such a node THIS tick and retry next tick LOSSLESS: an
80
- // unconnected peer has not drained anything (drained=0 preserved), so its events
81
- // are recovered whole on the next successful tick. Skip = delay, never loss.
82
- // getter WIRED (cloud) a null/undefined snapshot means "no peer object
83
- // right now" = NOT connected (a powered-off node whose failPeer just deleted
84
- // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
85
- // dialing here would re-queue for another 90s (the null-race the guard is
86
- // meant to prevent). Only a snapshot with state === 'connected' proceeds.
87
- // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
88
- // so this stays regression-free (the standalone case the guard's history
89
- // references).
90
- const getPeerStatus = components.getMeshPeerConnectionStatus;
91
- if (getPeerStatus) {
92
- const peerSnapshot = getPeerStatus(nodeDaemonId);
93
- if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
94
- }
67
+ // Pull one node's pending coordinator events and re-inject them locally via
68
+ // handleMeshForwardEvent (which runs the delivery-consume + re-queue paths). Factored
69
+ // out of pullRemoteNodeQueues so the reconcile loop can also issue a TARGETED single-node
70
+ // pull on demand specifically, the DELIVERED-NOT-CONSUMED redrive gate calls this for
71
+ // one node right before it would re-drive, so a worker's already-emitted (but not-yet-
72
+ // pulled) agent:generating_started is consumed IN-PROCESS this tick instead of waiting for
73
+ // the next PHASE 1 pull. That closes the redrive-vs-consume race: the redrive gate reads
74
+ // taskDeliveryConsumed() AFTER this pull has had its chance to flip the delivery row.
75
+ // Returns silently on any skip/error every caller treats it as best-effort.
76
+ export async function pullPendingEventsFromNode(
77
+ components: DaemonComponents,
78
+ meshId: string,
79
+ node: { daemonId?: string },
80
+ localDaemonId: string | undefined,
81
+ candidateDaemonIds: string[],
82
+ pulls: Array<Record<string, unknown>>,
83
+ ): Promise<void> {
84
+ const dispatchMeshCommand = components.dispatchMeshCommand;
85
+ if (!dispatchMeshCommand) return;
86
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
87
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
88
+ // already in the local queue drained in PHASE 2). "This daemon" is matched
89
+ // against the full self-identity set (candidateDaemonIds), not just the bare
90
+ // localDaemonId — a self node can be registered under the config-form daemonId
91
+ // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
92
+ // from ourselves over P2P is both wasteful and a self-dispatch hazard.
93
+ if (!nodeDaemonId) return;
94
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
95
+ if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
95
96
 
96
- for (const pendingEventArgs of pulls) {
97
- let events: unknown;
97
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
98
+ // a degraded peer whose DataChannel is not open would sink this pull into
99
+ // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
100
+ // the whole serial loop and delaying completion-event recovery from healthy
101
+ // nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
102
+ // unconnected peer has not drained anything (drained=0 preserved), so its events
103
+ // are recovered whole on the next successful tick. Skip = delay, never loss.
104
+ // • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
105
+ // right now" = NOT connected (a powered-off node whose failPeer just deleted
106
+ // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
107
+ // dialing here would re-queue for another 90s (the null-race the guard is
108
+ // meant to prevent). Only a snapshot with state === 'connected' proceeds.
109
+ // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
110
+ // so this stays regression-free (the standalone case the guard's history
111
+ // references).
112
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
113
+ if (getPeerStatus) {
114
+ const peerSnapshot = getPeerStatus(nodeDaemonId);
115
+ if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
116
+ }
117
+
118
+ for (const pendingEventArgs of pulls) {
119
+ let events: unknown;
120
+ try {
121
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
122
+ } catch {
123
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
124
+ break; // node unreachable — don't bother with the other id form this tick.
125
+ }
126
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
127
+ for (const event of list) {
128
+ const payload = buildForwardPayloadFromPending(event);
129
+ if (!payload.event || !payload.meshId) continue;
98
130
  try {
99
- events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
100
- } catch {
101
- // Remote pull is best-effort; the node may be offline. Retry next tick.
102
- break; // node unreachable — don't bother with the other id form this tick.
103
- }
104
- const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
105
- for (const event of list) {
106
- const payload = buildForwardPayloadFromPending(event);
107
- if (!payload.event || !payload.meshId) continue;
108
- try {
109
- handleMeshForwardEvent(components, payload);
110
- } catch { /* best-effort re-inject */ }
111
- }
131
+ handleMeshForwardEvent(components, payload);
132
+ } catch { /* best-effort re-inject */ }
112
133
  }
113
- }));
134
+ }
114
135
  }
115
136
 
116
137
  // Pull the read_chat payload out of whatever envelope the transport returned.
@@ -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.
@@ -0,0 +1,97 @@
1
+ import type {
2
+ AutoApproveMode,
3
+ AutoApproveModeRisk,
4
+ AutoApproveModeStrategy,
5
+ ProviderModule,
6
+ } from './contracts.js';
7
+
8
+ const KNOWN_DANGEROUS_LAUNCH_ARGS = new Set([
9
+ '--dangerously-skip-permissions',
10
+ '--dangerously-bypass-approvals-and-sandbox',
11
+ 'bypassPermissions',
12
+ 'sandbox_mode=danger-full-access',
13
+ 'approval_policy=never',
14
+ ]);
15
+
16
+ export interface ResolvedAutoApproveMode {
17
+ active: boolean;
18
+ strategy: AutoApproveModeStrategy;
19
+ modeId: string;
20
+ }
21
+
22
+ function isKnownDangerousLaunchArg(arg: string): boolean {
23
+ const normalized = arg.trim();
24
+ if (KNOWN_DANGEROUS_LAUNCH_ARGS.has(normalized)) return true;
25
+ return normalized.startsWith('--dangerously-skip-permissions=')
26
+ || normalized.startsWith('--dangerously-bypass-approvals-and-sandbox=');
27
+ }
28
+
29
+ /** Runtime defense-in-depth for provider definitions that bypass schema validation. */
30
+ export function deriveAutoApproveModeRisk(mode: Pick<AutoApproveMode, 'risk' | 'launchArgs'>): AutoApproveModeRisk {
31
+ return Array.isArray(mode.launchArgs) && mode.launchArgs.some(isKnownDangerousLaunchArg)
32
+ ? 'dangerous'
33
+ : mode.risk;
34
+ }
35
+
36
+ function inactiveMode(modeId = ''): ResolvedAutoApproveMode {
37
+ return { active: false, strategy: 'pty-parse-default', modeId };
38
+ }
39
+
40
+ function resolveConfiguredMode(
41
+ provider: ProviderModule,
42
+ mode: AutoApproveMode,
43
+ settings: Record<string, unknown> | undefined,
44
+ ): ResolvedAutoApproveMode {
45
+ if (mode.strategy === 'post-boot-command') return inactiveMode(mode.id);
46
+ if (settings?.launchedByCoordinator === true
47
+ && settings.delegatedWorkerDangerousModeAllow !== true
48
+ && deriveAutoApproveModeRisk(mode) === 'dangerous') {
49
+ const fallback = provider.autoApproveModes?.modes.find((candidate) =>
50
+ candidate.strategy === 'pty-parse-default'
51
+ && deriveAutoApproveModeRisk(candidate) !== 'dangerous');
52
+ return fallback
53
+ ? { active: true, strategy: fallback.strategy, modeId: fallback.id }
54
+ : inactiveMode(mode.id);
55
+ }
56
+ return { active: true, strategy: mode.strategy, modeId: mode.id };
57
+ }
58
+
59
+ /**
60
+ * Resolve new mode settings before the legacy boolean. A stale/unknown explicit
61
+ * mode id fails closed instead of falling through to an enabled legacy setting.
62
+ */
63
+ export function resolveProviderAutoApproveMode(
64
+ provider: ProviderModule,
65
+ settings: Record<string, unknown> | undefined,
66
+ ): ResolvedAutoApproveMode {
67
+ const config = provider.autoApproveModes;
68
+ const explicitModeId = settings?.autoApproveMode;
69
+ if (typeof explicitModeId === 'string') {
70
+ const mode = config?.modes.find((candidate) => candidate.id === explicitModeId);
71
+ if (!mode) return inactiveMode(explicitModeId);
72
+ return resolveConfiguredMode(provider, mode, settings);
73
+ }
74
+
75
+ const explicitLegacy = settings?.autoApprove;
76
+ const providerLegacyDefault = provider.settings?.autoApprove?.default;
77
+ const legacyActive = typeof explicitLegacy === 'boolean'
78
+ ? explicitLegacy
79
+ : typeof providerLegacyDefault === 'boolean'
80
+ ? providerLegacyDefault
81
+ : false;
82
+ if (!legacyActive) return inactiveMode();
83
+
84
+ if (!config) {
85
+ return { active: true, strategy: 'pty-parse-default', modeId: 'legacy' };
86
+ }
87
+ const defaultMode = config.modes.find((mode) => mode.id === config.default);
88
+ if (!defaultMode) return inactiveMode(config.default);
89
+ return resolveConfiguredMode(provider, defaultMode, settings);
90
+ }
91
+
92
+ export function findProviderAutoApproveMode(
93
+ provider: ProviderModule | undefined,
94
+ modeId: string,
95
+ ): AutoApproveMode | undefined {
96
+ return provider?.autoApproveModes?.modes.find((mode) => mode.id === modeId);
97
+ }
@@ -156,6 +156,59 @@ export function selectFinalAssistantTurnEndMessage(
156
156
  return null;
157
157
  }
158
158
 
159
+ /**
160
+ * EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
161
+ *
162
+ * True when the transcript's LAST non-empty user-facing assistant bubble is followed
163
+ * by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
164
+ * fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
165
+ * then started running Read/Grep, so the turn is still executing and its answer has
166
+ * not landed. Callers that would otherwise promote that preamble to a turn-end summary
167
+ * off a momentary (startup-grace / inter-tool) idle read use this as a veto.
168
+ *
169
+ * Deliberately NARROW so the pure-PTY completion rescue is preserved:
170
+ * - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
171
+ * status bubble does NOT (a finished turn can end on an internal thought), matching
172
+ * selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
173
+ * - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
174
+ * the kimi pure-PTY continuous-idle shape) returns false, so its early completion
175
+ * is untouched.
176
+ *
177
+ * Returns false when there is no final assistant bubble at all (the caller's
178
+ * selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
179
+ */
180
+ export function hasTrailingToolActivityAfterFinalAssistant(
181
+ messages: ChatMessage[] | null | undefined,
182
+ ): boolean {
183
+ if (!Array.isArray(messages) || messages.length === 0) return false;
184
+ let sawTrailingToolActivity = false;
185
+ for (let i = messages.length - 1; i >= 0; i--) {
186
+ const msg = messages[i];
187
+ if (!msg) continue;
188
+ const classification = classifyChatMessageVisibility(msg);
189
+ // A trailing user-facing assistant/model bubble with real text ends the scan:
190
+ // whether we saw a tool AFTER it is the verdict.
191
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
192
+ if (flattenContent(msg.content).trim()) return sawTrailingToolActivity;
193
+ // Empty streaming assistant bubble — keep scanning back past it, same as
194
+ // selectFinalAssistantTurnEndMessage (which returns null here); an empty tail
195
+ // isn't a turn end, so there is nothing to veto.
196
+ return false;
197
+ }
198
+ if (classification.isUserFacing) {
199
+ // A trailing user bubble (freshly dispatched task, no reply) → no assistant
200
+ // turn end below it to veto.
201
+ return false;
202
+ }
203
+ // Non-user-facing activity/internal bubble sitting AFTER the (not-yet-seen)
204
+ // assistant bubble. Only tool/terminal activity signals an in-flight turn.
205
+ if (classification.kind === 'tool' || classification.kind === 'terminal') {
206
+ sawTrailingToolActivity = true;
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+
159
212
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
160
213
 
161
214
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
@@ -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