@adhdev/daemon-core 0.9.82-rc.479 → 0.9.82-rc.480

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.
@@ -33,6 +33,14 @@ function toHostedCliRuntimeDescriptor(record: any): HostedCliRuntimeDescriptor |
33
33
  providerSessionId: typeof record.meta?.providerSessionId === 'string'
34
34
  ? String(record.meta.providerSessionId)
35
35
  : undefined,
36
+ // Real spawn time (PAST timestamp) of the underlying runtime — startedAt is
37
+ // stamped on markStarted; fall back to createdAt (record creation). Threaded
38
+ // through so an attach restores the native-history session-floor to the
39
+ // runtime's actual birth instead of collapsing spawnedAtMs to 0 (which broke
40
+ // the antigravity per-session birth-floor for co-located MAGI runtimes).
41
+ startedAtMs: typeof record.startedAt === 'number' && record.startedAt > 0
42
+ ? record.startedAt
43
+ : (typeof record.createdAt === 'number' && record.createdAt > 0 ? record.createdAt : undefined),
36
44
  };
37
45
  }
38
46
 
@@ -1742,6 +1742,11 @@ type ProviderNativeHistoryReadResult = {
1742
1742
  nativeHistoryCoverage?: string;
1743
1743
  partialReason?: string;
1744
1744
  unavailableReason?: string;
1745
+ // Antigravity: whether the resolved conversation was owner-token-confirmed as
1746
+ // this session's own (vs a bare recency pick). Threaded to the read-path so
1747
+ // the workspace-latest pin + first-read safe-mapping trust only fire for a
1748
+ // confirmed uuid. See dispatcher NativeHistoryResult.ownerConfirmed.
1749
+ ownerConfirmed?: boolean;
1745
1750
  };
1746
1751
 
1747
1752
  function getNativeHistoryScriptName(canonicalHistory: ProviderCanonicalHistoryConfig | undefined, key: 'readSession' | 'listSessions'): string {
@@ -1860,6 +1865,7 @@ function callProviderNativeHistoryRead(
1860
1865
  nativeHistoryCoverage: typeof (result as any).nativeHistoryCoverage === 'string' ? (result as any).nativeHistoryCoverage.trim() : undefined,
1861
1866
  partialReason: typeof (result as any).partialReason === 'string' ? (result as any).partialReason.trim() : undefined,
1862
1867
  unavailableReason: typeof (result as any).unavailableReason === 'string' ? (result as any).unavailableReason.trim() : undefined,
1868
+ ownerConfirmed: typeof (result as any).ownerConfirmed === 'boolean' ? (result as any).ownerConfirmed : undefined,
1863
1869
  };
1864
1870
  }
1865
1871
 
@@ -1957,6 +1963,7 @@ export function readProviderChatHistory(
1957
1963
  nativeHistoryCoverage?: string;
1958
1964
  partialReason?: string;
1959
1965
  unavailableReason?: string;
1966
+ ownerConfirmed?: boolean;
1960
1967
  } {
1961
1968
  if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
1962
1969
  const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn, options.sessionStartedAtMs, options.envOverrides, options.forceRefresh, options.instanceId);
@@ -1971,6 +1978,7 @@ export function readProviderChatHistory(
1971
1978
  nativeHistoryCoverage: nativeResult.nativeHistoryCoverage,
1972
1979
  partialReason: nativeResult.partialReason,
1973
1980
  unavailableReason: nativeResult.unavailableReason,
1981
+ ownerConfirmed: nativeResult.ownerConfirmed,
1974
1982
  };
1975
1983
  }
1976
1984
  return {
package/src/index.ts CHANGED
@@ -344,6 +344,7 @@ export { getHostMemorySnapshot } from './system/host-memory.js';
344
344
  export type { HostMemorySnapshot } from './system/host-memory.js';
345
345
  export {
346
346
  classifyHotChatSessionsForSubscriptionFlush,
347
+ detectNewlySettledCompletedSessions,
347
348
  DEFAULT_ACTIVE_CHAT_POLL_STATUSES,
348
349
  DEFAULT_CHAT_TAIL_RECENT_MESSAGE_GRACE_MS,
349
350
  } from './status/chat-tail-hot-sessions.js';
@@ -50,6 +50,19 @@ export interface NativeHistoryResult {
50
50
  sourcePath: string;
51
51
  sourceMtimeMs: number;
52
52
  nativeHistoryCoverage?: 'full' | 'partial' | 'best-effort';
53
+ /**
54
+ * True only when the resolved conversation is confirmed to belong to THIS
55
+ * reading session by the owner-token identity (an exact uuid bind, or a
56
+ * spawn-floor/birth-time pick born after this session started). False for a
57
+ * bare recency/newest-by-mtime pick made with no spawn floor — that pick may
58
+ * alias a co-located concurrent session (the antigravity coordinator↔replica
59
+ * crosswire), so its uuid must NEVER be recorded as a pin nor trusted to
60
+ * satisfy the same-pass safe-mapping identity check. Read-path callers gate
61
+ * the workspace-latest pin + first-read trust on this flag. Non-antigravity
62
+ * readers leave it undefined (their existing exact-file resolution is
63
+ * unaffected by this signal).
64
+ */
65
+ ownerConfirmed?: boolean;
53
66
  }
54
67
 
55
68
  export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeHistoryInput) => NativeHistoryResult | null {
@@ -74,8 +87,13 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
74
87
  : typeof input.args?.instanceId === 'string'
75
88
  ? (input.args.instanceId as string)
76
89
  : '';
77
- const sourcePath = resolveSourcePath(reader, workspace, sessionId, sessionStartedAtMs, instanceId);
90
+ const resolved = resolveSourcePath(reader, workspace, sessionId, sessionStartedAtMs, instanceId);
91
+ const sourcePath = resolved?.path || null;
78
92
  if (!sourcePath) return null;
93
+ // Owner-confirmation only meaningful for antigravity (see resolveAntigravityPath).
94
+ // For other readers the file was resolved by exact per-session key already;
95
+ // leave the flag undefined so the read-path callers treat them as before.
96
+ const ownerConfirmed = reader === 'antigravity-cli' ? resolved?.ownerConfirmed === true : undefined;
79
97
  if (input.forceRefresh === true || input.args?.forceRefresh === true) {
80
98
  try { fs.statSync(sourcePath); } catch { /* best-effort metadata refresh */ }
81
99
  }
@@ -116,6 +134,7 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
116
134
  sourcePath: session.sourcePath,
117
135
  sourceMtimeMs: session.sourceMtimeMs,
118
136
  nativeHistoryCoverage: (session as any).nativeHistoryCoverage || 'full',
137
+ ownerConfirmed,
119
138
  };
120
139
  };
121
140
  }
@@ -124,12 +143,19 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
124
143
  // Per-provider path resolution
125
144
  // ────────────────────────────────────────────────────────────────────────────
126
145
 
127
- function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: string, sessionStartedAtMs: number, instanceId: string): string | null {
146
+ interface ResolvedSource {
147
+ path: string;
148
+ /** Antigravity only: whether the resolution was owner-token-confirmed (see
149
+ * NativeHistoryResult.ownerConfirmed / resolveAntigravityPath). */
150
+ ownerConfirmed?: boolean;
151
+ }
152
+
153
+ function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: string, sessionStartedAtMs: number, instanceId: string): ResolvedSource | null {
128
154
  switch (reader) {
129
- case 'claude-cli': return resolveClaudePath(workspace, sessionId);
130
- case 'codex-cli': return resolveCodexPath(workspace, sessionId, sessionStartedAtMs);
155
+ case 'claude-cli': { const p = resolveClaudePath(workspace, sessionId); return p ? { path: p } : null; }
156
+ case 'codex-cli': { const p = resolveCodexPath(workspace, sessionId, sessionStartedAtMs); return p ? { path: p } : null; }
131
157
  case 'antigravity-cli': return resolveAntigravityPath(workspace, sessionId, sessionStartedAtMs, instanceId);
132
- case 'hermes-cli': return resolveHermesPath(workspace, sessionId);
158
+ case 'hermes-cli': { const p = resolveHermesPath(workspace, sessionId); return p ? { path: p } : null; }
133
159
  }
134
160
  }
135
161
 
@@ -291,7 +317,7 @@ function resolveAntigravityPath(
291
317
  sessionId: string,
292
318
  sessionStartedAtMs: number,
293
319
  instanceId: string,
294
- ): string | null {
320
+ ): ResolvedSource | null {
295
321
  const agyRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli');
296
322
  // Owner token identifies THIS reading session. The provider instance derives
297
323
  // the identical token (workspace + startedAt, or instanceId) so it can
@@ -309,7 +335,9 @@ function resolveAntigravityPath(
309
335
  const dbPath = path.join(agyRoot, 'conversations', `${sessionId}.db`);
310
336
  if (fs.existsSync(dbPath)) {
311
337
  if (owner) claimAntigravityConversation(sessionId, owner);
312
- return dbPath;
338
+ // Exact uuid bind — the caller named this conversation, so it is
339
+ // authoritatively this session's own. Owner-confirmed.
340
+ return { path: dbPath, ownerConfirmed: true };
313
341
  }
314
342
  }
315
343
 
@@ -344,6 +372,10 @@ function resolveAntigravityPath(
344
372
  })
345
373
  .filter(e => e.mtime >= cutoff);
346
374
  let ordered: Array<{ uuid: string; p: string }> = [];
375
+ // Floor branch (sessionStartedAtMs > 0) is birth-confirmed as this
376
+ // session's own store → owner-confirmed. Floor-less newest-by-mtime is a
377
+ // bare recency pick that can alias a co-located session → NOT confirmed.
378
+ const brainOwnerConfirmed = sessionStartedAtMs > 0;
347
379
  if (sessionStartedAtMs > 0) {
348
380
  // Floor branch: this session's own = born at/after (floor - grace),
349
381
  // oldest-birth first. Mirrors pickUnboundConversationDb exactly so the
@@ -360,7 +392,7 @@ function resolveAntigravityPath(
360
392
  const t = nonEmptyBrain(e.uuid, e.p);
361
393
  if (t) {
362
394
  if (owner) claimAntigravityConversation(e.uuid, owner);
363
- return t;
395
+ return { path: t, ownerConfirmed: brainOwnerConfirmed };
364
396
  }
365
397
  }
366
398
  }
@@ -372,7 +404,7 @@ function resolveAntigravityPath(
372
404
  const picked = pickUnboundConversationDb(convRoot, sessionStartedAtMs, owner);
373
405
  if (picked) {
374
406
  if (owner) claimAntigravityConversation(picked.uuid, owner);
375
- return picked.path;
407
+ return { path: picked.path, ownerConfirmed: picked.ownerConfirmed };
376
408
  }
377
409
 
378
410
  return null;
@@ -402,7 +434,7 @@ function pickUnboundConversationDb(
402
434
  convRoot: string,
403
435
  sessionFloorMs: number,
404
436
  owner: string,
405
- ): { path: string; uuid: string } | null {
437
+ ): { path: string; uuid: string; ownerConfirmed: boolean } | null {
406
438
  let entries: fs.Dirent[] = [];
407
439
  try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { return null; }
408
440
 
@@ -442,11 +474,15 @@ function pickUnboundConversationDb(
442
474
  // session — do NOT bind to it. Wait for our own store on the next read.
443
475
  if (own.length === 0) return null;
444
476
  own.sort((a, b) => (a.birth || a.mtime) - (b.birth || b.mtime));
445
- return { path: own[0].path, uuid: own[0].uuid };
477
+ // Birth-time confirmed as this session's own store → owner-confirmed.
478
+ return { path: own[0].path, uuid: own[0].uuid, ownerConfirmed: true };
446
479
  }
447
480
 
481
+ // Floor-less newest-by-mtime: a bare recency pick with no per-session
482
+ // guarantee — it can alias a co-located concurrent session, so it is NOT
483
+ // owner-confirmed and must not be pinned/trusted by the read-path callers.
448
484
  candidates.sort((a, b) => b.mtime - a.mtime);
449
- return { path: candidates[0].path, uuid: candidates[0].uuid };
485
+ return { path: candidates[0].path, uuid: candidates[0].uuid, ownerConfirmed: false };
450
486
  }
451
487
 
452
488
  /**
@@ -60,8 +60,35 @@ export function classifyHotChatSessionsForSubscriptionFlush(
60
60
  recentMessageGraceMs?: number;
61
61
  activeStatuses?: ReadonlySet<string>;
62
62
  activeSessionIds?: ReadonlySet<string>;
63
+ /**
64
+ * Per-session `lastMessageAt` of the most recent completion tail that has
65
+ * already been flushed to subscribers. A completed-but-unseen session is
66
+ * kept hot for delivery REGARDLESS of the 8s recency timer, but only until
67
+ * its current tail has been delivered once — bounding the guaranteed
68
+ * delivery so a slow-finalizing turn is not re-pushed every tick forever.
69
+ * A newer `lastMessageAt` (fresh turn) re-arms delivery.
70
+ */
71
+ deliveredCompletionTailAt?: ReadonlyMap<string, number>;
72
+ /**
73
+ * (D8) PER-SUBSCRIPTION ACK gate — the authoritative guaranteed-delivery
74
+ * signal. Session ids for which at least one currently-subscribed ws has
75
+ * NOT yet been sent the session's finalized tail (its per-ws
76
+ * `lastDeliveredSignature` is empty / does not match the session's current
77
+ * tail). The daemon computes this from its live ws-subscription map; the
78
+ * classifier keeps every such session hot-for-delivery INDEPENDENT of the
79
+ * seen/unread badge, so a session whose unread badge cleared (or a fresh
80
+ * browser that re-subscribed after the single completion flush already
81
+ * fired) still gets its assistant-final tail delivered. Bounded because a
82
+ * subscriber's `lastDeliveredSignature` converges to the current tail the
83
+ * moment it is actually sent, dropping the session out of this set. When
84
+ * provided, this set is the guaranteed-delivery driver and the
85
+ * `deliveredCompletionTailAt` recency-watermark path is bypassed for these
86
+ * sessions (it stays only as the legacy opt-in for callers that don't pass
87
+ * a per-subscription set).
88
+ */
89
+ underDeliveredSessionIds?: ReadonlySet<string>;
63
90
  } = {},
64
- ): { active: Set<string>; finalizing: Set<string> } {
91
+ ): { active: Set<string>; finalizing: Set<string>; guaranteedDelivery: Set<string> } {
65
92
  const now = options.now ?? Date.now();
66
93
  const recentMessageGraceMs = Math.max(
67
94
  0,
@@ -71,8 +98,19 @@ export function classifyHotChatSessionsForSubscriptionFlush(
71
98
  );
72
99
  const activeStatuses = options.activeStatuses ?? DEFAULT_ACTIVE_CHAT_POLL_STATUSES;
73
100
  const activeSessionIds = options.activeSessionIds ?? new Set<string>();
101
+ // The guaranteed-delivery path is opt-in: only callers that provide a
102
+ // delivered-watermark map (so they can BOUND re-pushes) participate. Callers
103
+ // without it keep the original recency-window-only behavior and never enter
104
+ // the completed-but-unseen keep-hot branch (which would otherwise thrash by
105
+ // re-pushing every tick with no delivered record to stop it).
106
+ const deliveredCompletionTailAt = options.deliveredCompletionTailAt ?? null;
107
+ const underDeliveredSessionIds = options.underDeliveredSessionIds ?? null;
74
108
  const active = new Set<string>();
75
109
  const excluded = new Set<string>();
110
+ // Sessions kept hot purely because they finalized late (outside the 8s
111
+ // window) and have not been delivered yet. The caller records these so the
112
+ // next classification knows their tail is now delivered.
113
+ const guaranteedDelivery = new Set<string>();
76
114
 
77
115
  for (const session of sessions) {
78
116
  const sessionId = typeof session?.id === 'string' ? session.id : '';
@@ -104,6 +142,49 @@ export function classifyHotChatSessionsForSubscriptionFlush(
104
142
 
105
143
  if (activeStatuses.has(status) || shouldKeepRecentTailHot) {
106
144
  active.add(sessionId);
145
+ continue;
146
+ }
147
+
148
+ // (D8) PER-SUBSCRIPTION ACK-BASED guaranteed delivery — the authoritative
149
+ // path. If ANY currently-subscribed ws for this session still lacks the
150
+ // finalized tail (empty / mismatched per-ws lastDeliveredSignature), keep
151
+ // the session hot-for-delivery REGARDLESS of the seen/unread badge. This is
152
+ // the fix for the agy coordinator stranded user-only: the single completion
153
+ // flush was marked "delivered" on FLUSH-FIRE (not browser-APPLIED), and
154
+ // re-delivery was gated on completedUnseen — so once the unread badge
155
+ // cleared, a subscriber that never actually applied the assistant tail (D6
156
+ // shrink-defer drop, or a fresh browser that re-subscribed after the flush
157
+ // instant) was permanently dropped and stayed user-only forever. Gating on
158
+ // "does a live subscriber still lack the tail" instead of the badge makes it
159
+ // re-arm for exactly those subscribers, and it self-bounds: the moment a
160
+ // subscriber is actually sent the tail its signature matches and it leaves
161
+ // this set (a same-tail re-flush is a cheap no-op via prepareSessionChatTailUpdate).
162
+ if (underDeliveredSessionIds && underDeliveredSessionIds.has(sessionId)) {
163
+ active.add(sessionId);
164
+ guaranteedDelivery.add(sessionId);
165
+ continue;
166
+ }
167
+
168
+ // Legacy recency-watermark guaranteed-delivery path (opt-in via
169
+ // deliveredCompletionTailAt): a completed-but-unseen session whose native
170
+ // tail finalized AFTER the 8s window (common for multi-turn MAGI
171
+ // coordinators — the native-history tail lags the last PTY echo). Kept for
172
+ // callers that do not supply a per-subscription ACK set. When the
173
+ // per-subscription set is provided it is authoritative and this path is not
174
+ // consulted (the `continue` above already handled under-delivered sessions;
175
+ // a session NOT in the set is genuinely delivered, so it must not be revived
176
+ // here on the stale badge alone).
177
+ const completedUnseen = unread || inboxBucket === 'task_complete';
178
+ if (!underDeliveredSessionIds && deliveredCompletionTailAt && completedUnseen) {
179
+ const delivered = deliveredCompletionTailAt.get(sessionId) ?? 0;
180
+ // Not yet delivered for this turn (no record, or a newer tail arrived
181
+ // since the last delivery). lastMessageAt===0 (unknown ts) still counts
182
+ // as undelivered so the tail is not silently dropped.
183
+ const alreadyDelivered = delivered > 0 && lastMessageAt > 0 && delivered >= lastMessageAt;
184
+ if (!alreadyDelivered) {
185
+ active.add(sessionId);
186
+ guaranteedDelivery.add(sessionId);
187
+ }
107
188
  }
108
189
  }
109
190
 
@@ -111,5 +192,39 @@ export function classifyHotChatSessionsForSubscriptionFlush(
111
192
  Array.from(previousHotSessionIds).filter((sessionId) => !active.has(sessionId) && !excluded.has(sessionId)),
112
193
  );
113
194
 
114
- return { active, finalizing };
195
+ return { active, finalizing, guaranteedDelivery };
196
+ }
197
+
198
+ /**
199
+ * Detect sessions that just transitioned from an active/generating state into
200
+ * a settled/completed-but-unseen state, so their finalized completion tail can
201
+ * be flushed exactly once regardless of the recency window. Pure: the caller
202
+ * owns the `previousStatus` map and updates it from the returned `nextStatus`.
203
+ */
204
+ export function detectNewlySettledCompletedSessions(
205
+ sessions: HotChatSessionLike[],
206
+ previousStatus: ReadonlyMap<string, string>,
207
+ options: { activeStatuses?: ReadonlySet<string> } = {},
208
+ ): { settled: Set<string>; nextStatus: Map<string, string> } {
209
+ const activeStatuses = options.activeStatuses ?? DEFAULT_ACTIVE_CHAT_POLL_STATUSES;
210
+ const settled = new Set<string>();
211
+ const nextStatus = new Map<string, string>();
212
+
213
+ for (const session of sessions) {
214
+ const sessionId = typeof session?.id === 'string' ? session.id : '';
215
+ if (!sessionId) continue;
216
+ const status = String(session?.status || '').toLowerCase();
217
+ const prevStatus = previousStatus.get(sessionId);
218
+ nextStatus.set(sessionId, status);
219
+
220
+ const wasActive = prevStatus !== undefined && activeStatuses.has(prevStatus);
221
+ const isSettledNow = !activeStatuses.has(status);
222
+ const inboxBucket = String(session?.inboxBucket || '').toLowerCase();
223
+ const completedUnseen = session?.unread === true || inboxBucket === 'task_complete';
224
+ if (wasActive && isSettledNow && completedUnseen) {
225
+ settled.add(sessionId);
226
+ }
227
+ }
228
+
229
+ return { settled, nextStatus };
115
230
  }