@adhdev/daemon-core 0.9.82-rc.476 → 0.9.82-rc.478

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.
@@ -11,6 +11,14 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
11
11
  } | {
12
12
  success: boolean;
13
13
  forwarded: number;
14
+ suppressed: boolean;
15
+ autoApprovingWorkerApproval: boolean;
16
+ error?: undefined;
17
+ } | {
18
+ success: boolean;
19
+ forwarded: number;
20
+ suppressed?: undefined;
21
+ autoApprovingWorkerApproval?: undefined;
14
22
  error?: undefined;
15
23
  } | {
16
24
  success: boolean;
@@ -308,6 +308,18 @@ export declare class CliProviderInstance implements ProviderInstance {
308
308
  private completedDebounceTimer;
309
309
  private completedDebouncePending;
310
310
  private lastExternalCompletionProbe;
311
+ /**
312
+ * The final assistant summary of the last completed turn, cached at
313
+ * completion-emit time. For a native-source provider (antigravity) whose
314
+ * assistant answer lives only in native-history — never in the PTY parse that
315
+ * feeds activeChat.messages — the dashboard's preview / lastMessageRole /
316
+ * completionMarker would otherwise never see the answer and show the session
317
+ * stuck on the user prompt. getState() appends this cached assistant bubble to
318
+ * the status messages when the PTY tail has none, so those fields reflect the
319
+ * real last answer with ZERO per-tick native reads (the native read already ran
320
+ * once at completion). Reset on the next turn's start.
321
+ */
322
+ private lastCompletionSummary;
311
323
  private enforceFreshSessionLaunchIfNeeded;
312
324
  private completionHasFinalAssistantMessage;
313
325
  private recordPendingTranscriptProbe;
@@ -321,6 +333,13 @@ export declare class CliProviderInstance implements ProviderInstance {
321
333
  */
322
334
  private spawnedEnvOverrides;
323
335
  private readExternalCompletionMessages;
336
+ /**
337
+ * The content of the LAST visible assistant bubble in a message list, or ''
338
+ * when the tail is not an assistant reply. Skips trailing system/tool/activity
339
+ * bubbles; stops (returns '') at the first user/human message. Used only for
340
+ * the dashboard tail-repair cache — a display value, not a completion decision.
341
+ */
342
+ private lastVisibleAssistantSummary;
324
343
  private completionFinalAssistantEvidence;
325
344
  private completionFinalSummary;
326
345
  private buildCompletedFinalizationDiagnostic;
@@ -1,12 +1,23 @@
1
1
  /** A claim older than this with no refresh is reclaimable (owner presumed dead). */
2
2
  export declare const CLAIM_STALE_MS: number;
3
3
  /**
4
- * Derive the per-session owner token. Both the dispatcher (from the read input:
5
- * workspace + sessionStartedAtMs) and the provider instance (from its
6
- * workingDir + startedAt, or its instanceId) call this with the same inputs so
7
- * claims and releases line up. Returns '' when there is no stable identity to
8
- * key on (e.g. a workspace-less discovery with no spawn time) — the caller then
9
- * skips claiming but the exclusion checks still run against existing claims.
4
+ * Derive the per-session owner token, keyed ONLY on the stable instanceId
5
+ * (== the session registry sessionId == the read path's targetSessionId). Both
6
+ * the dispatcher (read side) and the provider instance (claim/release side) pass
7
+ * this same id, so their tokens always agree and the claim isolation holds.
8
+ *
9
+ * Returns '' when no instanceId is available the caller then skips claiming
10
+ * (the exclusion checks still run against existing claims). This is the SSOT
11
+ * rule: there is exactly ONE token form. The removed legacy fallback derived a
12
+ * `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
13
+ * the instanceId was missing; because one session's spawn time is sampled
14
+ * independently at three sites (instance startedAt, adapter spawnedAtMs, registry
15
+ * spawnedAtMs) those never matched, so the SAME session's instance-side and
16
+ * read-side tokens silently diverged and the claim mutual-exclusion collapsed
17
+ * (the antigravity conversation crosswire). An empty token (skip-claim) is
18
+ * strictly safer than a token that disagrees with the same session's other
19
+ * token. workspace/sessionStartedAtMs are kept in the signature for call-site
20
+ * compatibility but no longer affect the token.
10
21
  */
11
22
  export declare function antigravityOwnerToken(workspace: string, sessionStartedAtMs: number, instanceId?: string): string;
12
23
  /**
@@ -13,6 +13,19 @@ export interface SessionRuntimeTarget {
13
13
  /** Wall clock at register time. native-history readers use it as a
14
14
  * cutoff so a fresh session can't show records from a prior one. */
15
15
  spawnedAtMs?: number;
16
+ /**
17
+ * Authoritative provider-native conversation id for this session (SSOT).
18
+ * For providers that expose a session id on the CLI (codex/claude/hermes)
19
+ * this equals that id. For antigravity — which takes no --session-id — this
20
+ * is the on-disk conversations/<uuid>.db basename, discovered by the
21
+ * native-history dispatcher and written back here via setProviderSessionId
22
+ * the first time it resolves. Every downstream reader (read_chat, the
23
+ * completion probe, the dashboard) should prefer this over re-deriving the
24
+ * conversation by spawn-floor/mtime heuristics — that re-derivation is the
25
+ * source of the antigravity conversation crosswire/theft class. Empty until
26
+ * the first successful native read binds it.
27
+ */
28
+ providerSessionId?: string;
16
29
  }
17
30
  export declare class SessionRegistry {
18
31
  private readonly bySessionId;
@@ -21,6 +34,13 @@ export declare class SessionRegistry {
21
34
  private readonly byParentSessionId;
22
35
  register(target: SessionRuntimeTarget): void;
23
36
  get(sessionId: string | undefined | null): SessionRuntimeTarget | undefined;
37
+ /**
38
+ * Record the authoritative provider-native conversation id for a session
39
+ * (SSOT). Idempotent; a no-op when the session is unknown or the value is
40
+ * empty or unchanged. Never overwrites a known binding with an empty one.
41
+ * Returns whether the stored value changed.
42
+ */
43
+ setProviderSessionId(sessionId: string | undefined | null, providerSessionId: string | undefined | null): boolean;
24
44
  unregister(sessionId: string | undefined | null): void;
25
45
  unregisterByManagerKey(managerKey: string): void;
26
46
  unregisterByInstanceKey(instanceKey: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.476",
3
+ "version": "0.9.82-rc.478",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.476",
51
- "@adhdev/session-host-core": "0.9.82-rc.476",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.478",
51
+ "@adhdev/session-host-core": "0.9.82-rc.478",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -91,10 +91,18 @@ function hydratePersistedProviderSessionPinsOnce(): void {
91
91
  }
92
92
  }
93
93
 
94
- function recordBoundProviderSessionId(meshSessionId: string | undefined, providerSessionId: string | undefined): void {
94
+ function recordBoundProviderSessionId(h: CommandHelpers, meshSessionId: string | undefined, providerSessionId: string | undefined): void {
95
95
  const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
96
96
  const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
97
97
  if (!key || !value) return;
98
+ // SSOT: the session registry entry (keyed by sessionId == instanceId) is the
99
+ // authoritative sessionId → conversation-uuid record. Writing it here — the
100
+ // moment a native read resolves the real conversation id — makes
101
+ // getHistorySessionId return it directly on every subsequent read, so the
102
+ // conversation is exact-bound instead of re-resolved by the spawn-floor/mtime
103
+ // heuristic (the crosswire/theft source). The pin below stays as the durable
104
+ // cross-restart mirror (the registry is in-memory and cleared on restart).
105
+ try { h.ctx?.sessionRegistry?.setProviderSessionId?.(key, value); } catch { /* best-effort SSOT write-back */ }
98
106
  lastBoundProviderSessionIdByMeshSession.set(key, value);
99
107
  // Always attempt the disk mirror — recordPersistedProviderSessionPin is itself a
100
108
  // no-op when the ON-DISK value already matches, so it does not rewrite state.json
@@ -1084,6 +1092,28 @@ function hasSafeNativeHistoryMapping(args: {
1084
1092
  // other. historySessionId (the provider-native session key) is required to
1085
1093
  // establish ownership. hasSafeNativeHistoryMapping() enforces the same
1086
1094
  // invariant after the read; both guards must hold for native history to be used.
1095
+ /**
1096
+ * The session id a native-history read should be scoped to: the explicit
1097
+ * targetSessionId when the caller named one (reading a specific/worker
1098
+ * session), otherwise the current live session (a self / dashboard read where
1099
+ * the current session IS the one being read). getTargetedCliAdapter already
1100
+ * uses this same fallback to resolve the adapter; the native-history floor and
1101
+ * claim-owner token must use it too. Without the fallback, a self-read arrives
1102
+ * with no targetSessionId → the floor collapses to undefined (→0) and the
1103
+ * antigravity claim-owner token collapses to '' → pickUnboundConversationDb
1104
+ * drops out of its spawn-floor branch into newest-by-mtime and binds whichever
1105
+ * conversation .db was written most recently. For an antigravity MAGI
1106
+ * coordinator that is exactly a co-located replica's .db (the replica finished
1107
+ * its turn last), so the coordinator's read cross-wires onto the replica's
1108
+ * conversation instead of its own (ANTIGRAVITY coordinator↔replica crosswire).
1109
+ */
1110
+ function effectiveReadSessionId(h: CommandHelpers, targetSessionId: string | undefined): string {
1111
+ const explicit = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
1112
+ if (explicit) return explicit;
1113
+ const current = (h.currentSession as any)?.sessionId;
1114
+ return typeof current === 'string' ? current.trim() : '';
1115
+ }
1116
+
1087
1117
  /**
1088
1118
  * Pull the session's spawnedAtMs out of the registry. Native-history
1089
1119
  * file pickers use it as a "files older than this can't be from this
@@ -1091,10 +1121,12 @@ function hasSafeNativeHistoryMapping(args: {
1091
1121
  * previous session's transcript whenever its file happened to be the
1092
1122
  * newest match. Returns undefined when the session isn't registered
1093
1123
  * (e.g. read_chat before the live session was wired up) — the executor
1094
- * treats undefined as "no floor".
1124
+ * treats undefined as "no floor". Resolves the effective session id
1125
+ * (targetSessionId or the current live session) so a self-read still gets its
1126
+ * real spawn floor rather than 0.
1095
1127
  */
1096
1128
  function sessionStartedAtMsFromRegistry(h: CommandHelpers, targetSessionId: string | undefined): number | undefined {
1097
- const sid = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
1129
+ const sid = effectiveReadSessionId(h, targetSessionId);
1098
1130
  if (!sid) return undefined;
1099
1131
  const target = h.ctx?.sessionRegistry?.get?.(sid);
1100
1132
  return typeof target?.spawnedAtMs === 'number' ? target.spawnedAtMs : undefined;
@@ -1597,7 +1629,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1597
1629
  scripts: provider?.scripts as any,
1598
1630
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
1599
1631
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1600
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1632
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1601
1633
  pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
1602
1634
  })
1603
1635
  : readProviderChatHistory(agentStr, {
@@ -1619,7 +1651,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1619
1651
  ? (result as any).providerSessionId
1620
1652
  : readHistorySessionIdFromMessages(messages) || historySessionId;
1621
1653
  if (typeof (result as any)?.providerSessionId === 'string' && (result as any).providerSessionId.trim()) {
1622
- recordBoundProviderSessionId(args?.targetSessionId, (result as any).providerSessionId.trim());
1654
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, args?.targetSessionId), (result as any).providerSessionId.trim());
1623
1655
  }
1624
1656
  const safeMapping = hasSafeNativeHistoryMapping({
1625
1657
  historySessionId: lookup === 'workspace' ? undefined : historySessionId,
@@ -1852,7 +1884,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1852
1884
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1853
1885
  // Stable per-session identity for antigravity's conversation-claim
1854
1886
  // owner token (== session registry sessionId == instance instanceId).
1855
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1887
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1856
1888
  pinnedProviderSessionId: pinnedProviderSessionIdForRead,
1857
1889
  // Last-resort only when no pin was ever recorded for this
1858
1890
  // session; the downstream workspace-overlap safety gate
@@ -1868,7 +1900,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1868
1900
  ? nativeHistory.providerSessionId.trim()
1869
1901
  : '';
1870
1902
  if (resolvedProviderSessionId) {
1871
- recordBoundProviderSessionId(targetSessionId, resolvedProviderSessionId);
1903
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSessionId), resolvedProviderSessionId);
1872
1904
  }
1873
1905
  } catch (error: any) {
1874
1906
  nativeHistoryError = error;
@@ -1928,7 +1960,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1928
1960
  excludeInProgressTurn: returnedStatus === 'waiting_approval',
1929
1961
  sessionStartedAtMs,
1930
1962
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1931
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1963
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1932
1964
  });
1933
1965
  nativeHistoryError = undefined;
1934
1966
  nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
@@ -2227,7 +2259,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2227
2259
  scripts: provider?.scripts as any,
2228
2260
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2229
2261
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2230
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
2262
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
2231
2263
  pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
2232
2264
  // Last-resort only when no pin was ever recorded AND the
2233
2265
  // runtime fallback did not resolve a real provider session.
@@ -2252,7 +2284,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2252
2284
  : readHistorySessionIdFromMessages(historyMessages) || effectiveHistorySessionIdForRead;
2253
2285
  // Refresh the pin whenever this path resolves a real provider id.
2254
2286
  if (typeof (history as any)?.providerSessionId === 'string' && (history as any).providerSessionId.trim()) {
2255
- recordBoundProviderSessionId(targetSid, (history as any).providerSessionId.trim());
2287
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSid), (history as any).providerSessionId.trim());
2256
2288
  }
2257
2289
  // Use the id we actually read with (pin / real provider id), NOT the
2258
2290
  // raw runtime-fallback historySessionId — otherwise the mapping guard
@@ -688,6 +688,31 @@ function evaluateMeshEventSuppression(
688
688
  return null;
689
689
  }
690
690
 
691
+ /**
692
+ * True when the worker session that emitted this event has auto-approve enabled
693
+ * (a MAGI/delegated worker is launched with autoApprove:true). Such a worker
694
+ * resolves its own approval modals locally, so an agent:waiting_approval event
695
+ * from it is transient noise for the coordinator: forwarding it injects a
696
+ * "[System] … is waiting for approval, use mesh_approve" turn that the
697
+ * coordinator cannot usefully act on (the modal is already auto-resolving) and,
698
+ * mid-MAGI-collect, HIJACKS the coordinator's synthesis turn — the observed
699
+ * failure where a replica's repeated auto-approvals drowned out the completion
700
+ * events and the coordinator answered about approvals instead of the RCA. Only
701
+ * suppress when we can positively confirm the source worker auto-approves; a
702
+ * worker that genuinely needs a human/coordinator approval (autoApprove off)
703
+ * still forwards so the coordinator is told.
704
+ */
705
+ function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: string): boolean {
706
+ if (!sessionId) return false;
707
+ try {
708
+ const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
709
+ const settings = (state?.settings as Record<string, unknown>) || {};
710
+ return settings.autoApprove === true;
711
+ } catch {
712
+ return false;
713
+ }
714
+ }
715
+
691
716
  function injectMeshSystemMessage(components: DaemonComponents, args: {
692
717
  meshId: string;
693
718
  sourceInstanceId?: string;
@@ -805,6 +830,19 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
805
830
  }
806
831
 
807
832
  const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
833
+
834
+ // Auto-approving worker: never forward its approval prompts to the coordinator.
835
+ // The daemon resolves the modal locally, so the "[System] … waiting for
836
+ // approval" injection is pure noise that hijacks the coordinator's turn — the
837
+ // observed MAGI failure where a replica's repeated auto-approvals flooded the
838
+ // coordinator and derailed its final synthesis. A worker without auto-approve
839
+ // (genuinely blocked on a human/coordinator decision) still forwards.
840
+ if (args.event === 'agent:waiting_approval' && sourceWorkerAutoApproves(components, eventSessionId)) {
841
+ LOG.info('MeshEvents', `Suppressed agent:waiting_approval for auto-approving worker session ${eventSessionId || '(unknown)'} (mesh ${args.meshId}) — modal is resolved locally, coordinator not notified`);
842
+ traceMeshEventDrop('waiting_approval_auto_approving_worker', traceCtx);
843
+ return { success: true, forwarded: 0, suppressed: true, autoApprovingWorkerApproval: true };
844
+ }
845
+
808
846
  // Coordinator-side dedup/suppression gate (extracted, behavior-preserving). A non-null
809
847
  // outcome either short-circuits with a forwarded result or signals a no-progress→completion
810
848
  // reconciliation that we re-inject; null lets the event fall through to the ledger machinery.
@@ -20,6 +20,7 @@ import { createCliAdapter } from './spec/route.js';
20
20
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
21
21
  import { StatusMonitor } from './status-monitor.js';
22
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
23
+ import { loadPersistedProviderSessionPins } from '../config/state-store.js';
23
24
  import { LOG } from '../logging/logger.js';
24
25
  import { recordDebugTrace } from '../logging/debug-trace.js';
25
26
  import { shouldCollectTraceCategory } from '../logging/debug-config.js';
@@ -572,7 +573,7 @@ export class CliProviderInstance implements ProviderInstance {
572
573
  const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
573
574
  ? this.syncCanonicalSavedHistoryIfNeeded()
574
575
  : false;
575
- const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
576
+ const statusMessages: any[] = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
576
577
  ? this.lastPersistedHistoryMessages.map((message) => ({
577
578
  role: message.role,
578
579
  content: message.content,
@@ -582,6 +583,42 @@ export class CliProviderInstance implements ProviderInstance {
582
583
  }))
583
584
  : mergedMessages;
584
585
 
586
+ // Dashboard-tail repair (native-source providers, e.g. antigravity): the
587
+ // assistant answer lives only in native-history, so the PTY-parsed
588
+ // statusMessages end on the user prompt / auto-approve system lines and the
589
+ // snapshot's preview / lastMessageRole / completionMarker never see the
590
+ // answer — the session looks stuck on the user turn. We already cached the
591
+ // real final assistant summary at completion time (lastCompletionSummary),
592
+ // so append it as the trailing assistant bubble when the current tail has no
593
+ // assistant message at/after it. Purely additive to the status view; no
594
+ // per-tick native read, no effect on providers whose PTY carries the
595
+ // assistant (they surface it themselves and the guard below is a no-op).
596
+ const adapterOwnsMessagesElsewhereForTail = (this.adapter as any)?.chatMessagesOwnedExternally === true;
597
+ if (adapterOwnsMessagesElsewhereForTail && this.lastCompletionSummary) {
598
+ const summary = this.lastCompletionSummary;
599
+ let hasTrailingAssistant = false;
600
+ for (let i = statusMessages.length - 1; i >= 0; i -= 1) {
601
+ const m = statusMessages[i] as { role?: string; kind?: string; receivedAt?: number };
602
+ const role = typeof m?.role === 'string' ? m.role : '';
603
+ if (role === 'system') continue;
604
+ if (typeof m?.kind === 'string' && m.kind === 'tool') continue;
605
+ // First non-system/non-tool message from the tail: if it's already an
606
+ // assistant reply not older than our cached summary, the tail is fine.
607
+ hasTrailingAssistant = role === 'assistant'
608
+ && typeof m?.receivedAt === 'number'
609
+ && m.receivedAt >= summary.receivedAt - 1000;
610
+ break;
611
+ }
612
+ if (!hasTrailingAssistant) {
613
+ statusMessages.push({
614
+ role: 'assistant',
615
+ content: summary.content,
616
+ kind: 'standard',
617
+ receivedAt: summary.receivedAt,
618
+ });
619
+ }
620
+ }
621
+
585
622
  const dirName = workingDirBasename(this.workingDir);
586
623
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
587
624
  ? parsedStatus.status.trim()
@@ -1132,6 +1169,18 @@ export class CliProviderInstance implements ProviderInstance {
1132
1169
  private completedDebounceTimer: NodeJS.Timeout | null = null;
1133
1170
  private completedDebouncePending: CompletedDebouncePending | null = null;
1134
1171
  private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
1172
+ /**
1173
+ * The final assistant summary of the last completed turn, cached at
1174
+ * completion-emit time. For a native-source provider (antigravity) whose
1175
+ * assistant answer lives only in native-history — never in the PTY parse that
1176
+ * feeds activeChat.messages — the dashboard's preview / lastMessageRole /
1177
+ * completionMarker would otherwise never see the answer and show the session
1178
+ * stuck on the user prompt. getState() appends this cached assistant bubble to
1179
+ * the status messages when the PTY tail has none, so those fields reflect the
1180
+ * real last answer with ZERO per-tick native reads (the native read already ran
1181
+ * once at completion). Reset on the next turn's start.
1182
+ */
1183
+ private lastCompletionSummary: { content: string; receivedAt: number } | null = null;
1135
1184
 
1136
1185
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
1137
1186
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -1219,21 +1268,59 @@ export class CliProviderInstance implements ProviderInstance {
1219
1268
  private readExternalCompletionMessages(): unknown[] | null {
1220
1269
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1221
1270
  if (!adapterOwnsMessagesElsewhere) return null;
1222
- if (!this.providerSessionId) return null;
1223
1271
  if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1224
1272
 
1273
+ // Resolve a CONCRETE native-history handle for this session's OWN
1274
+ // conversation. A provider that exposes its session id on the CLI
1275
+ // (codex/claude/hermes) sets this.providerSessionId. antigravity takes no
1276
+ // --session-id, so this.providerSessionId stays empty — recover the real
1277
+ // on-disk conversation id from the read pin persisted across restart
1278
+ // (state.json sessionProviderSessionPins, keyed by this session's
1279
+ // instanceId — the same map a binding read_chat / mesh_read_chat records
1280
+ // the resolved uuid into). The OLD `if (!this.providerSessionId) return
1281
+ // null` guard blocked antigravity's completion transcript entirely, so its
1282
+ // final-assistant evidence was permanently 'unavailable' and the turn
1283
+ // completion never emitted → the mesh reconcile loop reclaimed the
1284
+ // "delivered but no completion" task and re-dispatched the same MAGI prompt
1285
+ // (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, completion side).
1286
+ //
1287
+ // Prefer a concrete handle (providerSessionId, else the persisted pin). When
1288
+ // neither exists yet — a fresh antigravity turn whose conversation has not
1289
+ // been bound by any read_chat — fall through with an EMPTY handle and let the
1290
+ // dispatcher resolve this session's own conversations/<uuid>.db by the
1291
+ // spawn-floor + workspace + instanceId claim. This is safe now that the
1292
+ // claim owner token is single-form iid:<instanceId> (never collapses to '')
1293
+ // and pickUnboundConversationDb picks the oldest store born at/after THIS
1294
+ // session's floor: the probe resolves the session's OWN db under its own
1295
+ // owner token, so it can never steal a sibling's conversation (the earlier
1296
+ // theft required a floor=0 / owner='' collapse that no longer happens). It
1297
+ // means the completion summary is available on the FIRST completion — before
1298
+ // any read_chat has recorded a pin — which the dashboard tail-repair needs.
1299
+ let resolvedHandle = this.providerSessionId || '';
1300
+ if (!resolvedHandle) {
1301
+ try {
1302
+ const pinned = loadPersistedProviderSessionPins()[this.instanceId];
1303
+ if (typeof pinned === 'string' && pinned.trim()) resolvedHandle = pinned.trim();
1304
+ } catch { /* best-effort pin hydration */ }
1305
+ }
1306
+
1225
1307
  if (this.lastExternalCompletionProbe?.sourcePath) {
1226
1308
  try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1227
1309
  }
1228
1310
  const restoredHistory = readProviderChatHistory(this.type, {
1229
1311
  canonicalHistory: this.provider.nativeHistory,
1230
- historySessionId: this.providerSessionId,
1312
+ historySessionId: resolvedHandle || undefined,
1231
1313
  workspace: this.workingDir,
1232
1314
  offset: 0,
1233
1315
  limit: Number.MAX_SAFE_INTEGER,
1234
1316
  historyBehavior: this.provider.historyBehavior,
1235
1317
  scripts: this.provider.scripts as any,
1236
1318
  sessionStartedAtMs: this.startedAt,
1319
+ // The claim owner token must match read_chat's so the exact-bind on our
1320
+ // own conversation stays idempotent rather than looking foreign, and so
1321
+ // the floor-based resolution above claims THIS session's db under its
1322
+ // own owner (never a sibling's).
1323
+ instanceId: this.instanceId,
1237
1324
  envOverrides: this.spawnedEnvOverrides(),
1238
1325
  forceRefresh: true,
1239
1326
  });
@@ -1249,6 +1336,27 @@ export class CliProviderInstance implements ProviderInstance {
1249
1336
  return restoredHistory.messages;
1250
1337
  }
1251
1338
 
1339
+ /**
1340
+ * The content of the LAST visible assistant bubble in a message list, or ''
1341
+ * when the tail is not an assistant reply. Skips trailing system/tool/activity
1342
+ * bubbles; stops (returns '') at the first user/human message. Used only for
1343
+ * the dashboard tail-repair cache — a display value, not a completion decision.
1344
+ */
1345
+ private lastVisibleAssistantSummary(messages: unknown): string {
1346
+ if (!Array.isArray(messages)) return '';
1347
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1348
+ const m = messages[i] as { role?: string; kind?: string; content?: unknown };
1349
+ const role = typeof m?.role === 'string' ? m.role : '';
1350
+ const kind = typeof m?.kind === 'string' ? m.kind : '';
1351
+ if (role === 'system') continue;
1352
+ if (kind === 'tool' || kind === 'activity') continue;
1353
+ if (role === 'user' || role === 'human') return '';
1354
+ if (role === 'assistant') return flattenContent(m.content as any).trim();
1355
+ return '';
1356
+ }
1357
+ return '';
1358
+ }
1359
+
1252
1360
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1253
1361
  // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1254
1362
  // pure message-content check ("does the last visible bubble read as a finalized assistant
@@ -1274,8 +1382,22 @@ export class CliProviderInstance implements ProviderInstance {
1274
1382
 
1275
1383
  const externalMessages = this.readExternalCompletionMessages();
1276
1384
  if (externalMessages) {
1385
+ const present = turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
1386
+ // Dashboard tail-repair cache: this runs on EVERY completion check
1387
+ // (mesh AND non-mesh — the non-mesh path suppresses the
1388
+ // generating_completed emit, so completionFinalSummary never runs there
1389
+ // and cannot cache). Cache whenever the external transcript's LAST
1390
+ // visible bubble is an assistant reply — this is a display value only, so
1391
+ // it is intentionally looser than the strict `present` completion gate
1392
+ // (which also requires turnClosed and turn-scoping): the dashboard should
1393
+ // show the answer as soon as native-history has it, even if the FSM has
1394
+ // not yet ratified the turn end. getState() replaces it on the next turn.
1395
+ const lastVisibleAssistant = this.lastVisibleAssistantSummary(externalMessages);
1396
+ if (lastVisibleAssistant) {
1397
+ this.lastCompletionSummary = { content: lastVisibleAssistant, receivedAt: Date.now() };
1398
+ }
1277
1399
  return {
1278
- present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1400
+ present,
1279
1401
  messages: externalMessages,
1280
1402
  source: 'external-native',
1281
1403
  };
@@ -1328,7 +1450,15 @@ export class CliProviderInstance implements ProviderInstance {
1328
1450
  // The transcript is authoritative for native-source providers. Use it unless it is
1329
1451
  // empty (not yet written, or no in-turn bubble) — only then fall back to the screen
1330
1452
  // parse, which reflects the LIVE screen (this turn's output), not the stale tail.
1331
- if (externalSummary) return externalSummary;
1453
+ if (externalSummary) {
1454
+ // Cache the resolved final assistant for the dashboard tail-repair in
1455
+ // getState(). This runs on EVERY completion attempt (including non-mesh
1456
+ // sessions whose generating_completed is suppressed, and short-gen
1457
+ // settle paths), so the dashboard sees the answer even when no
1458
+ // agent:generating_completed is ever emitted. Native read already ran.
1459
+ this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now() };
1460
+ return externalSummary;
1461
+ }
1332
1462
  return parsedSummary || undefined;
1333
1463
  }
1334
1464
  return parsedSummary || undefined;
@@ -1941,6 +2071,14 @@ export class CliProviderInstance implements ProviderInstance {
1941
2071
  evidenceLevel?: string;
1942
2072
  completionDiagnostic?: Record<string, unknown>;
1943
2073
  }): void {
2074
+ // Cache the final assistant summary so the dashboard snapshot can surface it
2075
+ // for native-source providers whose assistant answer is absent from the PTY
2076
+ // parse (antigravity). completionFinalSummary already read native-history to
2077
+ // produce this, so nothing extra is read here.
2078
+ const summary = typeof opts.finalSummary === 'string' ? opts.finalSummary.trim() : '';
2079
+ if (summary) {
2080
+ this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
2081
+ }
1944
2082
  this.pushEvent({
1945
2083
  event: 'agent:generating_completed',
1946
2084
  chatTitle: opts.chatTitle,
@@ -2465,6 +2603,11 @@ export class CliProviderInstance implements ProviderInstance {
2465
2603
  }
2466
2604
 
2467
2605
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2606
+ // A genuinely new turn is underway — drop the previous turn's cached
2607
+ // final-summary so the dashboard does not keep showing the old answer
2608
+ // as "done" while the new turn generates. Re-populated when this turn
2609
+ // completes.
2610
+ this.lastCompletionSummary = null;
2468
2611
  // FALSE-IDLE continuity: entering a busy phase invalidates any
2469
2612
  // completedDebouncePending armed earlier in this settle window.
2470
2613
  this.busyEpoch++;
@@ -51,25 +51,32 @@ function normalizeUuid(uuid: string): string {
51
51
  }
52
52
 
53
53
  /**
54
- * Derive the per-session owner token. Both the dispatcher (from the read input:
55
- * workspace + sessionStartedAtMs) and the provider instance (from its
56
- * workingDir + startedAt, or its instanceId) call this with the same inputs so
57
- * claims and releases line up. Returns '' when there is no stable identity to
58
- * key on (e.g. a workspace-less discovery with no spawn time) — the caller then
59
- * skips claiming but the exclusion checks still run against existing claims.
54
+ * Derive the per-session owner token, keyed ONLY on the stable instanceId
55
+ * (== the session registry sessionId == the read path's targetSessionId). Both
56
+ * the dispatcher (read side) and the provider instance (claim/release side) pass
57
+ * this same id, so their tokens always agree and the claim isolation holds.
58
+ *
59
+ * Returns '' when no instanceId is available the caller then skips claiming
60
+ * (the exclusion checks still run against existing claims). This is the SSOT
61
+ * rule: there is exactly ONE token form. The removed legacy fallback derived a
62
+ * `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
63
+ * the instanceId was missing; because one session's spawn time is sampled
64
+ * independently at three sites (instance startedAt, adapter spawnedAtMs, registry
65
+ * spawnedAtMs) those never matched, so the SAME session's instance-side and
66
+ * read-side tokens silently diverged and the claim mutual-exclusion collapsed
67
+ * (the antigravity conversation crosswire). An empty token (skip-claim) is
68
+ * strictly safer than a token that disagrees with the same session's other
69
+ * token. workspace/sessionStartedAtMs are kept in the signature for call-site
70
+ * compatibility but no longer affect the token.
60
71
  */
61
72
  export function antigravityOwnerToken(
62
73
  workspace: string,
63
74
  sessionStartedAtMs: number,
64
75
  instanceId?: string,
65
76
  ): string {
77
+ void workspace; void sessionStartedAtMs;
66
78
  const iid = typeof instanceId === 'string' ? instanceId.trim() : '';
67
- if (iid) return `iid:${iid}`;
68
- if (typeof sessionStartedAtMs === 'number' && sessionStartedAtMs > 0) {
69
- const ws = String(workspace || '').trim().toLowerCase();
70
- return `spawn:${ws}:${sessionStartedAtMs}`;
71
- }
72
- return '';
79
+ return iid ? `iid:${iid}` : '';
73
80
  }
74
81
 
75
82
  /**