@adhdev/daemon-core 0.9.82-rc.252 → 0.9.82-rc.254

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.
@@ -56,7 +56,6 @@ export declare class CliProviderInstance implements ProviderInstance {
56
56
  private runtimeMessages;
57
57
  private lastPersistedHistoryMessages;
58
58
  private lastAcknowledgedUserInputAt;
59
- private externalBusyIdleFingerprint;
60
59
  private lastNativeSourceCanonicalCheckAt;
61
60
  private lastNativeSourceCanonicalCacheKey;
62
61
  private cachedSqliteDb;
@@ -133,8 +132,6 @@ export declare class CliProviderInstance implements ProviderInstance {
133
132
  private readExternalCompletionMessages;
134
133
  private completionFinalAssistantEvidence;
135
134
  private completionFinalSummary;
136
- private externalNativeFinalFingerprint;
137
- private getExternalNativeFinalReconciliation;
138
135
  private buildCompletedFinalizationDiagnostic;
139
136
  private hasAdapterPendingResponse;
140
137
  private shouldSuppressStaleParsedBusyStatus;
@@ -10,6 +10,7 @@ export type DashboardEvent = {
10
10
  id: string;
11
11
  label: string;
12
12
  title: string | null;
13
+ status: 'idle' | 'generating' | 'approval';
13
14
  };
14
15
  modal: {
15
16
  title: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.252",
3
+ "version": "0.9.82-rc.254",
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",
@@ -627,10 +627,12 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
627
627
  if (nodeId) cachedById.set(nodeId, node);
628
628
  }
629
629
 
630
+ const mergedIncomingIds = new Set<string>();
630
631
  const nodes = incomingNodes.map((incomingNode: any) => {
631
632
  const nodeId = readInlineMeshNodeId(incomingNode);
632
633
  const cachedNode = nodeId ? cachedById.get(nodeId) : undefined;
633
634
  if (!cachedNode && preserveCachedMembership) return null;
635
+ if (nodeId) mergedIncomingIds.add(nodeId);
634
636
  if (!cachedNode) return incomingNode;
635
637
  if (hasInlineMeshTransientNodeState(incomingNode)) {
636
638
  return { ...cachedNode, ...incomingNode };
@@ -638,6 +640,21 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
638
640
  return { ...stripInlineMeshTransientNodeState(cachedNode), ...incomingNode };
639
641
  }).filter(Boolean);
640
642
 
643
+ // When the cached membership is authoritative (newer than the incoming
644
+ // snapshot), nodes that exist only in the cache must survive reconciliation.
645
+ // A freshly cloned worktree node lives only in the coordinator's cache until
646
+ // the next snapshot catches up; iterating incomingNodes alone would silently
647
+ // drop it, making the node invisible to get_mesh / membership reads even
648
+ // though worktree_bootstrap_complete already fired.
649
+ if (preserveCachedMembership) {
650
+ for (const cachedNode of cachedNodes) {
651
+ const nodeId = readInlineMeshNodeId(cachedNode);
652
+ if (nodeId && !mergedIncomingIds.has(nodeId)) {
653
+ nodes.push(cachedNode);
654
+ }
655
+ }
656
+ }
657
+
641
658
  return {
642
659
  ...cached,
643
660
  ...incoming,
@@ -6005,7 +6022,16 @@ export class DaemonCommandRouter {
6005
6022
  if (ownerFailure) return ownerFailure;
6006
6023
 
6007
6024
  try {
6008
- const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
6025
+ // Resolve with preferInline so the clone writes the new node into the
6026
+ // same representation that get_mesh reads back. The MCP coordinator
6027
+ // passes inlineMesh on every mesh command, so when it owns an inline
6028
+ // mesh the membership read path (get_mesh, preferInline: true) returns
6029
+ // the inline cache. Without preferInline here, clone could resolve to a
6030
+ // local-config mesh and write the node only to config — leaving the
6031
+ // inline cache (and therefore get_mesh / refreshMeshFromDaemon) without
6032
+ // the node, so the new worktree node is never visible in live mesh
6033
+ // membership even though worktree_bootstrap_complete fires.
6034
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
6009
6035
  const mesh = meshRecord?.mesh;
6010
6036
  if (!mesh) return { success: false, error: 'Mesh not found' };
6011
6037
 
@@ -6064,6 +6090,13 @@ export class DaemonCommandRouter {
6064
6090
  policy: { ...(sourceNode.policy || {}) },
6065
6091
  });
6066
6092
  if (!node) return { success: false, error: 'Failed to register worktree node' };
6093
+ // Also reconcile the freshly-registered node into any warmed inline
6094
+ // cache for this mesh. get_mesh (preferInline: true) reads the inline
6095
+ // cache first when one exists; if we only wrote to local config the
6096
+ // node would be invisible to membership reads. updateInlineMeshNode is
6097
+ // a no-op when no inline cache is present.
6098
+ const inlineForReconcile = this.getCachedInlineMesh(meshId);
6099
+ if (inlineForReconcile) this.updateInlineMeshNode(meshId, inlineForReconcile, node);
6067
6100
  this.invalidateAggregateMeshStatus(meshId);
6068
6101
  }
6069
6102
 
@@ -438,7 +438,21 @@ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
438
438
  }
439
439
 
440
440
  function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
441
- return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId);
441
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
442
+ return true;
443
+ }
444
+ // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
445
+ // work queue. A session completing a still-active direct dispatch IS an active
446
+ // assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
447
+ // the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
448
+ // so the coordinator polling get_pending_mesh_events never observes task_completed and the
449
+ // session goes silently idle. This check runs before markSessionTerminal marks the
450
+ // dispatch terminal, so the in-flight dispatch is still observable here.
451
+ try {
452
+ if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
453
+ if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
454
+ } catch { /* best-effort — fall through to false */ }
455
+ return false;
442
456
  }
443
457
 
444
458
  function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
@@ -927,6 +941,27 @@ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string
927
941
  return typeof eventName === 'string' && MESH_COORDINATOR_EVENTS.has(eventName);
928
942
  }
929
943
 
944
+ // Terminal events that the coordinator is actively blocked waiting on. When the
945
+ // coordinator CLI session dispatches a task (e.g. mesh_send_task) it stays in
946
+ // `generating` until the result arrives — but a generating coordinator queues
947
+ // incoming send_message calls into its adapter's pendingOutboundQueue, which is
948
+ // only flushed on the coordinator's OWN idle transition. That transition can't
949
+ // happen until it receives this very event → deadlock. We force-inject these so
950
+ // they bypass the busy send-guard and land in the PTY while generating.
951
+ const MESH_FORCE_INJECT_EVENTS = new Set([
952
+ 'agent:generating_completed',
953
+ 'agent:stopped',
954
+ 'agent:waiting_approval',
955
+ 'refine:completed',
956
+ 'refine:failed',
957
+ 'worktree_bootstrap_complete',
958
+ 'worktree_bootstrap_failed',
959
+ ]);
960
+
961
+ function shouldForceInjectMeshEvent(eventName: unknown): boolean {
962
+ return typeof eventName === 'string' && MESH_FORCE_INJECT_EVENTS.has(eventName);
963
+ }
964
+
930
965
  function injectMeshSystemMessage(components: DaemonComponents, args: {
931
966
  meshId: string;
932
967
  sourceInstanceId?: string;
@@ -1362,10 +1397,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1362
1397
  LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
1363
1398
  }
1364
1399
 
1400
+ const forceInject = shouldForceInjectMeshEvent(args.event);
1365
1401
  for (const coord of coordinatorInstances) {
1366
1402
  const coordState = coord.getState();
1367
- LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}`);
1368
- coord.onEvent('send_message', { input: { text: messageText, textFallback: messageText } });
1403
+ LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}${forceInject ? ' (force)' : ''}`);
1404
+ coord.onEvent('send_message', {
1405
+ input: { text: messageText, textFallback: messageText },
1406
+ ...(forceInject ? { force: true } : {}),
1407
+ });
1369
1408
  }
1370
1409
  return { success: true, forwarded: coordinatorInstances.length };
1371
1410
  }
@@ -1424,6 +1463,58 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1424
1463
 
1425
1464
  export function setupMeshEventForwarding(components: DaemonComponents) {
1426
1465
  components.instanceManager.onEvent((event) => {
1466
+ // --- Coordinator idle auto-flush ---
1467
+ // When a coordinator session becomes idle, flush any pending coordinator events
1468
+ // that accumulated while it was generating. This runs before the delegate routing
1469
+ // below so that coordinator-own idle transitions are handled first.
1470
+ // Exception: a coordinator that is itself a direct-dispatch target still needs
1471
+ // to go through delegate routing so that the dispatching coordinator receives a
1472
+ // pendingCoordinatorEvents entry for the completion.
1473
+ if (event.event === 'agent:ready' || event.event === 'agent:generating_completed') {
1474
+ const flushInstanceId = readNonEmptyString(event.instanceId);
1475
+ if (flushInstanceId) {
1476
+ const flushSource = components.instanceManager.getInstance(flushInstanceId);
1477
+ if (flushSource && flushSource.category === 'cli') {
1478
+ const flushState = flushSource.getState();
1479
+ const flushSettings = flushState.settings && typeof flushState.settings === 'object' ? flushState.settings as Record<string, unknown> : {};
1480
+ const coordinatorMeshId = readNonEmptyString(flushSettings.meshCoordinatorFor);
1481
+ if (coordinatorMeshId) {
1482
+ const status = readNonEmptyString(flushState.status).toLowerCase();
1483
+ if (status === 'idle') {
1484
+ try {
1485
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
1486
+ const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
1487
+ if (pendingEvents.length > 0) {
1488
+ LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
1489
+ for (const pending of pendingEvents) {
1490
+ if (!pending.coordinatorMessage) continue;
1491
+ const forcePending = shouldForceInjectMeshEvent(pending.event);
1492
+ flushSource.onEvent('send_message', {
1493
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
1494
+ ...(forcePending ? { force: true } : {}),
1495
+ });
1496
+ }
1497
+ }
1498
+ } catch (e: any) {
1499
+ LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
1500
+ }
1501
+ }
1502
+ // Skip delegate routing unless this coordinator session is itself
1503
+ // a direct-dispatch target — in that case fall through so the
1504
+ // dispatching coordinator gets a pendingCoordinatorEvents entry.
1505
+ let hasDirectDispatch = false;
1506
+ try {
1507
+ hasDirectDispatch =
1508
+ getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === flushInstanceId)
1509
+ || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, flushInstanceId);
1510
+ } catch { /* best-effort */ }
1511
+ if (!hasDirectDispatch) return;
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ // --- Delegate event routing ---
1427
1518
  if (!isMeshCoordinatorEvent(event.event)) return;
1428
1519
 
1429
1520
  const instanceId = readNonEmptyString(event.instanceId);
@@ -1475,36 +1566,4 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1475
1566
  metadataEvent: event,
1476
1567
  });
1477
1568
  });
1478
-
1479
- // Auto-flush pending coordinator events when a coordinator session becomes idle.
1480
- components.instanceManager.onEvent((event) => {
1481
- if (event.event !== 'agent:ready' && event.event !== 'agent:generating_completed') return;
1482
-
1483
- const instanceId = readNonEmptyString(event.instanceId);
1484
- if (!instanceId) return;
1485
-
1486
- const sourceInstance = components.instanceManager.getInstance(instanceId);
1487
- if (!sourceInstance || sourceInstance.category !== 'cli') return;
1488
- const state = sourceInstance.getState();
1489
- const settings = state.settings && typeof state.settings === 'object' ? state.settings as Record<string, unknown> : {};
1490
-
1491
- const coordinatorMeshId = readNonEmptyString(settings.meshCoordinatorFor);
1492
- if (!coordinatorMeshId) return;
1493
-
1494
- const status = readNonEmptyString(state.status).toLowerCase();
1495
- if (status !== 'idle') return;
1496
-
1497
- try {
1498
- const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
1499
- const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
1500
- if (pendingEvents.length === 0) return;
1501
- LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
1502
- for (const pending of pendingEvents) {
1503
- if (!pending.coordinatorMessage) continue;
1504
- sourceInstance.onEvent('send_message', { input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage } });
1505
- }
1506
- } catch (e: any) {
1507
- LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
1508
- }
1509
- });
1510
1569
  }
@@ -185,12 +185,20 @@ function refineTerminalEventFromLedger(meshId: string, pending: readonly Pending
185
185
 
186
186
  function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
187
187
  const backfilled = refineTerminalEventFromLedger(meshId, events);
188
- if (backfilled.length === 0) return events;
189
- const terminalJobIds = new Set(backfilled.map(event => readRefineJobId(event)).filter(Boolean));
190
- return [
191
- ...events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event)))),
192
- ...backfilled,
193
- ];
188
+ // A refine:accepted event is a provisional "job accepted, result to follow" signal.
189
+ // Once its terminal (completed/failed) counterpart for the same jobId exists — whether
190
+ // already direct-queued into the pending store OR backfilled from the ledger here — the
191
+ // accepted is superseded and is dropped so the coordinator isn't shown stale duplicate
192
+ // noise alongside the terminal outcome.
193
+ const terminalJobIds = new Set(
194
+ [...events.filter(event => REFINE_TERMINAL_EVENTS.has(event.event)), ...backfilled]
195
+ .map(event => readRefineJobId(event))
196
+ .filter(Boolean),
197
+ );
198
+ const reconciled = terminalJobIds.size === 0
199
+ ? events
200
+ : events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event))));
201
+ return backfilled.length === 0 ? reconciled : [...reconciled, ...backfilled];
194
202
  }
195
203
 
196
204
  const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
@@ -22,7 +22,6 @@ export interface MeshTaskModeValidationResult {
22
22
 
23
23
  const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
24
24
  { label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
25
- { label: 'git_mutation', pattern: /\b(?:git\s+(?:add|commit|push|reset|rebase|clean|checkout|switch|merge|tag|restore|rm|mv|stash|worktree\s+(?:add|remove|move))|push\b)/i },
26
25
  { label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
27
26
  { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
28
27
  { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
@@ -30,6 +29,61 @@ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> =
30
29
  { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
31
30
  ];
32
31
 
32
+ /**
33
+ * Git subcommands that mutate the working tree, index, refs, or remote.
34
+ * `stash` and `checkout` are intentionally absent here: they have read-only
35
+ * variants (`git stash list`/`show`, `git checkout-index`) and are classified
36
+ * token-by-token in {@link detectGitMutation} rather than by bare keyword.
37
+ */
38
+ const GIT_MUTATION_SUBCOMMANDS = new Set([
39
+ 'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
40
+ 'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
41
+ 'am', 'apply', 'gc', 'prune',
42
+ ]);
43
+
44
+ /**
45
+ * Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
46
+ * push/save/clear, or bare `git stash` which defaults to push) is a mutation.
47
+ */
48
+ const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
49
+
50
+ /**
51
+ * Detects a true git mutation in free-text task message, token-aware so that
52
+ * read-only diagnostics (`git stash list`, `git stash show --stat`,
53
+ * `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
54
+ * Returns true only when a genuine mutating git invocation is present.
55
+ */
56
+ function detectGitMutation(message: string): boolean {
57
+ const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
58
+ let match: RegExpExecArray | null;
59
+ while ((match = re.exec(message)) !== null) {
60
+ const sub = match[1].toLowerCase();
61
+ if (GIT_MUTATION_SUBCOMMANDS.has(sub)) return true;
62
+ if (sub === 'stash') {
63
+ // Token following `git stash`; read-only only for list/show.
64
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
65
+ const next = after ? after[1].toLowerCase() : '';
66
+ if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next)) return true; // bare stash = push, or pop/apply/drop/...
67
+ } else if (sub === 'checkout') {
68
+ // `git checkout <ref/path>` mutates; `git checkout-index` is matched
69
+ // as its own token by the regex (sub === 'checkout-index') and is read-only.
70
+ return true;
71
+ } else if (sub === 'submodule') {
72
+ // `git submodule update` mutates; `git submodule status` is read-only.
73
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
74
+ const next = after ? after[1].toLowerCase() : '';
75
+ if (next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') return true;
76
+ } else if (sub === 'worktree') {
77
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
78
+ const next = after ? after[1].toLowerCase() : '';
79
+ if (next === 'add' || next === 'remove' || next === 'move' || next === 'prune') return true;
80
+ }
81
+ // checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
82
+ // rev-parse/branch/submodule status fall through as read-only.
83
+ }
84
+ return false;
85
+ }
86
+
33
87
  export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
34
88
  if (typeof value !== 'string') return undefined;
35
89
  const normalized = value.trim() as MeshTaskMode;
@@ -44,9 +98,13 @@ export function validateMeshTaskModeRequest(mode: unknown, message: string): Mes
44
98
  if (taskMode !== 'live_debug_readonly') {
45
99
  return { valid: true, taskMode, violations: [] };
46
100
  }
101
+ const text = message || '';
47
102
  const violations = LIVE_DEBUG_READONLY_FORBIDDEN
48
- .filter(rule => rule.pattern.test(message || ''))
103
+ .filter(rule => rule.pattern.test(text))
49
104
  .map(rule => rule.label);
105
+ if (detectGitMutation(text)) {
106
+ violations.push('git_mutation');
107
+ }
50
108
  return {
51
109
  valid: violations.length === 0,
52
110
  taskMode,
@@ -73,12 +73,6 @@ type CompletionFinalAssistantEvidence = {
73
73
  source: 'parsed' | 'external-native' | 'unavailable';
74
74
  };
75
75
 
76
- type ExternalNativeFinalReconciliation = {
77
- fingerprint: string;
78
- finalSummary: string;
79
- evidence: CompletionFinalAssistantEvidence;
80
- };
81
-
82
76
  type ExternalTranscriptProbe = {
83
77
  readAt: number;
84
78
  msgCount: number;
@@ -376,7 +370,6 @@ export class CliProviderInstance implements ProviderInstance {
376
370
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
377
371
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
378
372
  private lastAcknowledgedUserInputAt = 0;
379
- private externalBusyIdleFingerprint = '';
380
373
  private lastNativeSourceCanonicalCheckAt = 0;
381
374
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
382
375
  private cachedSqliteDb: {
@@ -599,17 +592,11 @@ export class CliProviderInstance implements ProviderInstance {
599
592
  let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
600
593
  ? 'error'
601
594
  : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
602
- const externalNativeFinal = this.getExternalNativeFinalReconciliation(parsedStatus?.messages, adapterStatus);
603
- if (externalNativeFinal && isCliGeneratingLikeStatus(visibleStatus)) {
604
- visibleStatus = 'idle';
605
- }
606
- // Adapter raw status can lag behind parsed/native evidence: if the spec driver
607
- // has not yet emitted a state_changed(idle) event but the parsed transcript
608
- // already shows a final assistant turn, treat the session as idle so that
609
- // getState() agrees with what detectStatusTransition already recorded via
610
- // lastStatus. Without this guard, getState() returns 'generating' even after
611
- // the instance's lastStatus has flipped to 'idle', causing the dashboard to
612
- // show a perpetual generating spinner.
595
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
596
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
597
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
598
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
599
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
613
600
  if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
614
601
  visibleStatus = 'idle';
615
602
  }
@@ -885,7 +872,13 @@ export class CliProviderInstance implements ProviderInstance {
885
872
  assertProviderSupportsDeclaredInput(this.provider, input);
886
873
  const promptText = buildCliStructuredInputPrompt(input);
887
874
  if (promptText) {
888
- void this.adapter.sendMessage(promptText).catch((e: any) => {
875
+ // force:true bypasses the busy/generating send guard so terminal mesh
876
+ // events (completion/failure/bootstrap) land in a coordinator session that
877
+ // is itself parked in `generating` while awaiting that very event.
878
+ // Without it the message is queued and only flushed on the coordinator's
879
+ // own idle transition — which never happens until it receives the message.
880
+ const force = data?.force === true;
881
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
889
882
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
890
883
  });
891
884
  }
@@ -934,7 +927,6 @@ export class CliProviderInstance implements ProviderInstance {
934
927
 
935
928
  const receivedAt = Date.now();
936
929
  this.lastAcknowledgedUserInputAt = receivedAt;
937
- this.externalBusyIdleFingerprint = '';
938
930
  const dedupKey = `user_input_ack:${crypto
939
931
  .createHash('sha256')
940
932
  .update(`${this.instanceId}:${content}:${receivedAt}`)
@@ -1100,59 +1092,6 @@ export class CliProviderInstance implements ProviderInstance {
1100
1092
  return extractFinalSummaryFromMessages(evidence.messages as any);
1101
1093
  }
1102
1094
 
1103
- private externalNativeFinalFingerprint(evidence: CompletionFinalAssistantEvidence): string {
1104
- const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
1105
- const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1106
- const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1107
- const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
1108
- const receivedAt = lastVisible ? getMessageTime(lastVisible) : 0;
1109
- const probe = this.lastExternalCompletionProbe;
1110
- return crypto
1111
- .createHash('sha256')
1112
- .update([
1113
- this.type,
1114
- this.providerSessionId || '',
1115
- probe?.sourcePath || '',
1116
- String(probe?.sourceMtimeMs || 0),
1117
- String(receivedAt || 0),
1118
- content.slice(-500),
1119
- ].join('\0'))
1120
- .digest('hex')
1121
- .slice(0, 24);
1122
- }
1123
-
1124
- private getExternalNativeFinalReconciliation(parsedMessages: unknown, adapterStatus: any): ExternalNativeFinalReconciliation | null {
1125
- const rawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
1126
- if (!isCliGeneratingLikeStatus(rawStatus)) return null;
1127
- if (hasNonEmptyCliModalButtons(adapterStatus?.activeModal ?? adapterStatus?.modal)) return null;
1128
-
1129
- const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1130
- if (evidence.source !== 'external-native' || !evidence.present) return null;
1131
-
1132
- const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
1133
- const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1134
- const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1135
- const lastMessageAt = lastVisible ? getMessageTime(lastVisible) : 0;
1136
- const sourceMtimeMs = Number(this.lastExternalCompletionProbe?.sourceMtimeMs || 0);
1137
- const minEvidenceAt = Math.max(
1138
- this.startedAt > 0 ? this.startedAt - 5_000 : 0,
1139
- this.generatingStartedAt > 0 ? this.generatingStartedAt - 5_000 : 0,
1140
- this.lastAcknowledgedUserInputAt > 0 ? this.lastAcknowledgedUserInputAt - 1_000 : 0,
1141
- );
1142
- if (minEvidenceAt > 0 && lastMessageAt > 0 && lastMessageAt < minEvidenceAt && sourceMtimeMs < minEvidenceAt) {
1143
- return null;
1144
- }
1145
-
1146
- const finalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1147
- if (!finalSummary) return null;
1148
- const fingerprint = this.externalNativeFinalFingerprint(evidence);
1149
- if (fingerprint === this.externalBusyIdleFingerprint) {
1150
- return { fingerprint, finalSummary, evidence };
1151
- }
1152
- this.externalBusyIdleFingerprint = fingerprint;
1153
- return { fingerprint, finalSummary, evidence };
1154
- }
1155
-
1156
1095
  private buildCompletedFinalizationDiagnostic(args: {
1157
1096
  blockReason: string;
1158
1097
  latestStatus?: any;
@@ -1242,13 +1181,12 @@ export class CliProviderInstance implements ProviderInstance {
1242
1181
  return true;
1243
1182
  }
1244
1183
 
1245
- private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending, opts?: { externalNativeFinal?: ExternalNativeFinalReconciliation | null }): CompletedFinalizationBlock | null {
1184
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1246
1185
  if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
1247
1186
 
1248
1187
  const adapterAny = this.adapter as any;
1249
1188
  const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1250
- const externalNativeFinal = opts?.externalNativeFinal || null;
1251
- if (!approvalResolvedIdle && !externalNativeFinal) {
1189
+ if (!approvalResolvedIdle) {
1252
1190
  if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1253
1191
  if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1254
1192
  if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
@@ -1257,7 +1195,7 @@ export class CliProviderInstance implements ProviderInstance {
1257
1195
  const partial = typeof this.adapter.getPartialResponse === 'function'
1258
1196
  ? this.adapter.getPartialResponse()
1259
1197
  : '';
1260
- if (!externalNativeFinal && typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
1198
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
1261
1199
 
1262
1200
  let parsed: any;
1263
1201
  try {
@@ -1269,7 +1207,6 @@ export class CliProviderInstance implements ProviderInstance {
1269
1207
  const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
1270
1208
  if (parsedStatus !== 'idle') {
1271
1209
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
1272
- if (externalNativeFinal && isCliGeneratingLikeStatus(parsedStatus)) return null;
1273
1210
  if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1274
1211
  return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1275
1212
  }
@@ -1341,11 +1278,8 @@ export class CliProviderInstance implements ProviderInstance {
1341
1278
 
1342
1279
  const latestStatus = this.adapter.getStatus({ allowParse: false });
1343
1280
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
1344
- const externalNativeFinal = this.getExternalNativeFinalReconciliation(undefined, latestStatus);
1345
- const latestVisibleStatus = externalNativeFinal && isCliGeneratingLikeStatus(latestStatus.status)
1346
- ? 'idle'
1347
- : (latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status);
1348
- LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1281
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1282
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
1349
1283
  if (latestVisibleStatus !== 'idle') {
1350
1284
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1351
1285
  this.completedDebouncePending = null;
@@ -1353,7 +1287,7 @@ export class CliProviderInstance implements ProviderInstance {
1353
1287
  return;
1354
1288
  }
1355
1289
 
1356
- const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending, { externalNativeFinal });
1290
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1357
1291
  if (block) {
1358
1292
  const blockReason = block.reason;
1359
1293
  const waitedMs = Date.now() - pending.firstObservedAt;
@@ -1398,18 +1332,7 @@ export class CliProviderInstance implements ProviderInstance {
1398
1332
  chatTitle: pending.chatTitle,
1399
1333
  duration: pending.duration,
1400
1334
  timestamp: pending.timestamp,
1401
- finalSummary: externalNativeFinal?.finalSummary || this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1402
- ...(externalNativeFinal ? {
1403
- completionDiagnostic: {
1404
- providerType: this.type,
1405
- sessionId: this.instanceId,
1406
- providerSessionId: this.providerSessionId || null,
1407
- reconciliationReason: 'external_native_final_assistant_while_adapter_busy',
1408
- finalAssistantPresent: true,
1409
- finalAssistantEvidenceSource: externalNativeFinal.evidence.source,
1410
- externalFinalFingerprint: externalNativeFinal.fingerprint,
1411
- },
1412
- } : {}),
1335
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1413
1336
  });
1414
1337
  this.completedDebouncePending = null;
1415
1338
  this.completedDebounceTimer = null;
@@ -1486,15 +1409,13 @@ export class CliProviderInstance implements ProviderInstance {
1486
1409
  const parsedStatus = null;
1487
1410
  const rawStatus = adapterStatus.status;
1488
1411
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
1489
- const externalNativeFinal = this.getExternalNativeFinalReconciliation(undefined, adapterStatus);
1490
1412
  // During the autoApproveBusy window (2s after firing approval key), the PTY
1491
1413
  // can briefly report 'idle' before the next generating phase starts. Treat that
1492
1414
  // transient idle as 'generating' to suppress a spurious agent:generating_completed
1493
- // push notification. externalNativeFinal still wins to allow hard-stop overrides.
1415
+ // push notification. The adapter's status is otherwise authoritative — native
1416
+ // transcript shape does NOT override the FSM's busy/idle decision.
1494
1417
  const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1495
- const newStatus = externalNativeFinal && isCliGeneratingLikeStatus(rawStatus)
1496
- ? 'idle'
1497
- : (autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus);
1418
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
1498
1419
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
1499
1420
  const chatTitle = `${this.provider.name} · ${dirName}`;
1500
1421
  const partial = this.adapter.getPartialResponse();
@@ -68,7 +68,7 @@ export class SpecCliAdapter implements CliAdapter {
68
68
  native_history?: NativeHistoryConfig;
69
69
  };
70
70
  private lastEvent: DashboardEvent | null = null;
71
- private latestState: { id: string; label: string; title: string | null } | null = null;
71
+ private latestState: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' } | null = null;
72
72
  private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
73
73
  private statusCallback: (() => void) | null = null;
74
74
  private ptyDataCallback: ((data: string) => void) | null = null;
@@ -159,21 +159,25 @@ export class SpecCliAdapter implements CliAdapter {
159
159
  const state = this.latestState;
160
160
  if (!state) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
161
161
 
162
+ // The FSM state is authoritative for status. We do NOT infer status from whether
163
+ // a modal was parsed this frame: a modal/approval state whose buttons briefly fail
164
+ // to parse (PTY repaint) must still report waiting_approval, not collapse to idle —
165
+ // that collapse fired false completions while a session sat at an approval prompt.
162
166
  const modal = this.latestModal;
163
- const lc = state.id.toLowerCase();
164
- if (modal) {
167
+ if (state.status === 'approval') {
165
168
  return {
166
169
  status: 'waiting_approval',
167
170
  messages: [],
168
- activeModal: {
169
- message: modal.title ?? state.label,
170
- buttons: modal.buttons.map(b => b.label),
171
- },
171
+ // Surface buttons when we have them; an approval state with no parsed
172
+ // modal this frame still stays waiting_approval (no activeModal yet).
173
+ activeModal: modal
174
+ ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label) }
175
+ : null,
172
176
  activeInteractivePrompt: this.activeInteractivePrompt,
173
177
  ...sessionFields,
174
178
  };
175
179
  }
176
- if (lc === 'busy' || lc === 'generating') {
180
+ if (state.status === 'generating') {
177
181
  return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
178
182
  }
179
183
  return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };