From dc27f660d02beb8f0d9a7aa6d76043ee0aa5882b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:53:41 -0700 Subject: [PATCH] fix(native-chat): retract status by authoritative visible catalog --- .../structured-agent-session-host.ts | 17 +++--- ...tured-agent-session-status-catalog.test.ts | 41 ++++++++++++++ ...structured-agent-session-status-catalog.ts | 25 +++++++++ ...ructured-agent-session-status-feed.test.ts | 38 +++++++++++++ .../structured-agent-session-status-feed.ts | 51 +++++++++++++++-- .../runtime/agent-session-record-store.ts | 14 ++--- .../agent-session-visible-tab-index.ts | 18 ++++++ ...ructured-agent-session-status-feed.test.ts | 54 ++++++++++++++++++ .../structured-agent-session-status-feed.ts | 55 +++++++++++++++---- .../src/store/slices/runtime-status.ts | 7 +++ src/shared/agent-session-wire.ts | 12 +++- 11 files changed, 296 insertions(+), 36 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 257c7a4e4f7..4212463cde5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -54,7 +54,8 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { createHostStatusFeed } from './structured-agent-session-status-catalog' +import type { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -65,12 +66,7 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new Map() - private readonly statusFeed = new StructuredAgentSessionStatusFeed({ - sessions: this.sessions, - getRecord: (sessionId) => this.deps.store.getRecord(sessionId), - now: () => this.now(), - onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options) - }) + private readonly statusFeed: StructuredAgentSessionStatusFeed private readonly subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId), onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal) @@ -87,6 +83,7 @@ export class StructuredAgentSessionHost { private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.statusFeed = createHostStatusFeed(deps, this.sessions, () => this.now()) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, this.sessions, @@ -216,8 +213,10 @@ export class StructuredAgentSessionHost { getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => this.deps.store.getVisibleSessionTabIndex() - setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => - this.deps.store.setSessionTabVisibility(sessionId, visible) + setSessionTabVisibility = async (sessionId: string, visible: boolean): Promise => { + await this.deps.store.setSessionTabVisibility(sessionId, visible) + this.statusFeed.visibilityChanged(sessionId, visible) + } reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.test.ts new file mode 100644 index 00000000000..c8598450a87 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { createHostStatusFeed } from './structured-agent-session-status-catalog' + +describe('status catalog completeness', () => { + it.each([ + { present: true, readOnly: false, recoveredFromBackup: false, complete: true }, + { present: false, readOnly: false, recoveredFromBackup: false, complete: false }, + { present: true, readOnly: true, recoveredFromBackup: false, complete: false }, + { present: true, readOnly: false, recoveredFromBackup: true, complete: false } + ])('keeps startup authority conservative: %o', (input) => { + let present = input.present + const store = { + readOnly: input.readOnly, + recoveredFromBackup: input.recoveredFromBackup, + getVisibleSessionTabIndex: () => ({ present, sessionIds: ['still-restoring'] }), + listVisibleSessionIds: () => ['still-restoring'] + } as unknown as AgentSessionRecordStore + const feed = createHostStatusFeed( + { store } as StructuredAgentSessionHostDeps, + new Map(), + () => 1 + ) + present = true + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'reader', emit: (event) => events.push(event) }) + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [], + catalog: { + epoch: expect.any(String), + complete: input.complete, + sessionIds: ['still-restoring'] + } + } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.ts new file mode 100644 index 00000000000..201837594ca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-catalog.ts @@ -0,0 +1,25 @@ +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusFeedDeps +} from './structured-agent-session-status-feed' + +export function createHostStatusFeed( + deps: StructuredAgentSessionHostDeps, + sessions: StructuredAgentSessionStatusFeedDeps['sessions'], + now: () => number +): StructuredAgentSessionStatusFeed { + // Legacy hydration writes visibility incrementally; only an already loaded index is complete. + const complete = + deps.store.getVisibleSessionTabIndex().present && + !deps.store.readOnly && + !deps.store.recoveredFromBackup + return new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: (sessionId) => deps.store.getRecord(sessionId), + now, + catalog: () => ({ complete, sessionIds: deps.store.listVisibleSessionIds() }), + isVisible: (sessionId) => deps.store.isSessionTabVisible(sessionId), + onStatusChanged: (summary, options) => deps.onSessionStatusChanged?.(summary, options) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index c1b52f879d4..fe95a0dd47f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -522,4 +522,42 @@ describe('StructuredAgentSessionStatusFeed', () => { session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) }) }) + it('bounds retention by explicit visibility without suppressing hidden runtime observations', async () => { + const journal = await openJournal() + const sessions = new Map>() + const visible = new Set() + const observed: string[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => 1, + isVisible: (id) => visible.has(id), + catalog: () => ({ complete: true, sessionIds: [...visible] }), + onStatusChanged: (summary) => observed.push(summary.sessionId) + }) + for (let i = 0; i < 3000; i++) { + const id = String(i) + sessions.set(id, indexed({ journal })) + visible.add(id) + feed.visibilityChanged(id, true) + visible.delete(id) + feed.visibilityChanged(id, false) + feed.publish(id) + sessions.delete(id) + } + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'late', emit: (event) => events.push(event) }) + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [], + catalog: { + complete: true, + epoch: expect.any(String), + sessionIds: [] + } + } + ]) + expect(observed.length).toBeGreaterThan(0) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cfd85ff4648..557d1572507 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -8,8 +8,9 @@ // The last projection is kept after the session's provider child is evicted: an idle session is // still idle without a process, and a renderer that reloads must not lose every settled row until // each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions -// republishes them. +// republishes them. Explicit tab removal retracts only the catalog projection. +import { randomUUID } from 'node:crypto' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' @@ -36,6 +37,8 @@ export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null now: () => number + catalog?: () => { complete: boolean; sessionIds: string[] } + isVisible?: (sessionId: string) => boolean /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void @@ -62,6 +65,9 @@ export class StructuredAgentSessionStatusFeed { private readonly subscribers = new Map() private readonly published = new Map() + private readonly epoch = randomUUID() + private readonly lastProjection = new WeakMap() + constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} /** Opens with every session this host has projected, live ones re-read, then only changes. */ @@ -72,7 +78,7 @@ export class StructuredAgentSessionStatusFeed { this.publish(sessionId, undefined, { replay: true }) } this.subscribers.set(subscriber.id, subscriber) - this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] }) + this.emit(subscriber, this.snapshot()) return () => this.unsubscribe(subscriber.id) } @@ -95,13 +101,20 @@ export class StructuredAgentSessionStatusFeed { if (!session) { return } - const summary = this.summaryFor(sessionId, session, journal ?? session.journal) - const previous = this.published.get(sessionId) + const source = journal ?? session.journal + const summary = this.summaryFor(sessionId, session, source) + const previous = this.lastProjection.get(source) ?? this.published.get(sessionId) + const retained = this.published.get(sessionId) + if (this.deps.isVisible?.(sessionId) !== false) { + if (!retained || !summariesEqual(retained, summary)) { + this.published.set(sessionId, summary) + this.broadcast({ type: 'status', session: summary }) + } + } + this.lastProjection.set(source, summary) if (previous && summariesEqual(previous, summary)) { return } - this.published.set(sessionId, summary) - this.broadcast({ type: 'status', session: summary }) try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { @@ -110,6 +123,32 @@ export class StructuredAgentSessionStatusFeed { } } + visibilityChanged(sessionId: string, visible: boolean): void { + if (visible) { + this.publish(sessionId, undefined, { replay: true }) + } else { + this.published.delete(sessionId) + this.broadcast({ type: 'snapshot', sessions: [], removedSessionIds: [sessionId] }) + } + } + + private snapshot(): AgentSessionStatusEvent { + const catalog = this.deps.catalog?.() + if (catalog?.complete) { + const visible = new Set(catalog.sessionIds) + for (const id of this.published.keys()) { + if (!visible.has(id)) { + this.published.delete(id) + } + } + } + return { + type: 'snapshot', + sessions: [...this.published.values()], + ...(catalog ? { catalog: { ...catalog, epoch: this.epoch } } : {}) + } + } + private summaryFor( sessionId: string, session: StatusFeedSession, diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 4325410ed81..43f2fde3052 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,4 +1,4 @@ -import { setVisibleSessionId } from './agent-session-visible-tab-index' +import * as visibleTabs from './agent-session-visible-tab-index' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' /** Durable single-writer session records and their operation ledger. */ @@ -117,17 +117,15 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] - listVisibleSessionIds = (): string[] => - [...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId)) + isSessionTabVisible = (id: string) => visibleTabs.isSessionTabVisible(this.state, id) - getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.visibleSessionIdsIndexPresent, - sessionIds: this.listVisibleSessionIds() - }) + listVisibleSessionIds = () => visibleTabs.listVisibleSessionIds(this.state) + + getVisibleSessionTabIndex = () => visibleTabs.visibleSessionTabIndex(this.state) /** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */ setSessionTabVisibility(sessionId: string, visible: boolean): Promise { - return this.transact(() => setVisibleSessionId(this.state, sessionId, visible)) + return this.transact(() => visibleTabs.setVisibleSessionId(this.state, sessionId, visible)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { diff --git a/src/main/runtime/agent-session-visible-tab-index.ts b/src/main/runtime/agent-session-visible-tab-index.ts index e0f7b9b4c5c..c55a481e0a9 100644 --- a/src/main/runtime/agent-session-visible-tab-index.ts +++ b/src/main/runtime/agent-session-visible-tab-index.ts @@ -36,3 +36,21 @@ export function setVisibleSessionId( } state.visibleSessionIdsIndexPresent = true } + +export function isSessionTabVisible(state: AgentSessionStoreState, sessionId: string): boolean { + return !state.visibleSessionIdsIndexPresent || state.visibleSessionIds.has(sessionId) +} + +export function visibleSessionTabIndex(state: AgentSessionStoreState): { + present: boolean + sessionIds: string[] +} { + return { + present: state.visibleSessionIdsIndexPresent, + sessionIds: listVisibleSessionIds(state) + } +} + +export function listVisibleSessionIds(state: AgentSessionStoreState): string[] { + return [...state.visibleSessionIds].filter((id) => state.records.has(id)) +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index d9cb4dd3d72..e5c1b8c34f8 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -21,6 +21,7 @@ vi.mock('./runtime-rpc-client', () => ({ import { getStructuredAgentSessionStatusFeed, + invalidateStructuredAgentSessionStatusFeed, resetStructuredAgentSessionStatusFeedsForTests } from './structured-agent-session-status-feed' @@ -137,4 +138,57 @@ describe('structured agent session status feed', () => { await vi.advanceTimersByTimeAsync(10_000) expect(mocks.subscribeStatus).toHaveBeenCalledOnce() }) + it('retracts only complete catalog absences and explicit removals', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + hostEmit()({ type: 'snapshot', sessions: [summary('a'), summary('b'), summary('c')] }) + hostEmit()({ + type: 'snapshot', + sessions: [], + catalog: { epoch: 'one', complete: false, sessionIds: [] } + }) + expect(feed.getSnapshot().size).toBe(3) + hostEmit()({ + type: 'snapshot', + sessions: [], + catalog: { epoch: 'two', complete: true, sessionIds: ['a', 'b'] } + }) + expect([...feed.getSnapshot().keys()]).toEqual(['a', 'b']) + hostEmit()({ type: 'snapshot', sessions: [], removedSessionIds: ['a'] }) + expect([...feed.getSnapshot().keys()]).toEqual(['b']) + }) + + it('fences closed streams immediately and keeps retained counts bounded under churn', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const emit = hostEmit() + for (let i = 0; i < 3000; i++) { + emit({ type: 'status', session: summary(String(i)) }) + emit({ type: 'snapshot', sessions: [], removedSessionIds: [String(i)] }) + } + expect(feed.getSnapshot().size).toBe(0) + emit({ type: 'end' }) + emit({ type: 'status', session: summary('late') }) + expect(feed.getSnapshot().size).toBe(0) + }) + + it('disposes removed runtime owners and clears paired identity state before reopening', async () => { + const feed = getStructuredAgentSessionStatusFeed(REMOTE) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const emit = hostEmit() + emit({ type: 'status', session: summary('old-account') }) + invalidateStructuredAgentSessionStatusFeed('env-1', false) + expect(feed.getSnapshot().size).toBe(0) + emit({ type: 'status', session: summary('stale-account') }) + expect(feed.getSnapshot().size).toBe(0) + await vi.advanceTimersByTimeAsync(0) + expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2) + invalidateStructuredAgentSessionStatusFeed('env-1', true) + hostEmit(1)({ type: 'status', session: summary('late') }) + expect(feed.getSnapshot().size).toBe(0) + expect(getStructuredAgentSessionStatusFeed(REMOTE)).not.toBe(feed) + }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 2b550679315..86712d8ef40 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -2,8 +2,7 @@ // // The feed is a read-only mirror: the host projects each session's status from its journal and // this owner keeps the latest summary per session while anyone is looking. Losing the stream -// keeps the cached summaries and reconnects; a fresh snapshot merges over them. -// Which sessions are listed is the tab map's decision, so the feed never retracts a summary. +// keeps cached summaries and reconnects; only complete catalogs or explicit removals retract. import type { AgentSessionStatusEvent, @@ -27,7 +26,10 @@ export type StructuredAgentSessionStatusFeedOwner = { const RECONNECT_MAX_DELAY_MS = 5_000 /** `stop` is the map's own teardown, not part of the owner contract callers hold. */ -type OwnedStatusFeed = StructuredAgentSessionStatusFeedOwner & { stop: () => void } +type OwnedStatusFeed = StructuredAgentSessionStatusFeedOwner & { + stop: () => void + invalidate: (removed: boolean) => void +} const owners = new Map() @@ -40,6 +42,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { const listeners = new Set<() => void>() const activations = new Set() let generation = 0 + let disposed = false let handle: { unsubscribe: () => void } | null = null let reconnectTimer: ReturnType | null = null let reconnectAttempt = 0 @@ -56,9 +59,12 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { const applyEvent = (event: AgentSessionStatusEvent): void => { if (event.type === 'snapshot') { reconnectAttempt = 0 - // Merged, not replaced: a restarted host restores its readable sessions asynchronously, so - // the first snapshot can be empty and dropping those rows flickers every one to no-status. - const next = new Map(snapshot) + // An empty projection during restore is not an empty catalog. + const visible = event.catalog?.complete ? new Set(event.catalog.sessionIds) : null + const next = new Map([...snapshot].filter(([id]) => !visible || visible.has(id))) + for (const id of event.removedSessionIds ?? []) { + next.delete(id) + } for (const session of event.sessions) { next.set(session.sessionId, session) } @@ -104,22 +110,25 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { + generation += 1 dropHandle() - scheduleReconnect(candidate) + scheduleReconnect(generation) return } applyEvent(event) }, () => { if (active(candidate)) { + generation += 1 dropHandle() - scheduleReconnect(candidate) + scheduleReconnect(generation) } }, () => { if (active(candidate)) { + generation += 1 dropHandle() - scheduleReconnect(candidate) + scheduleReconnect(generation) } } ) @@ -168,6 +177,9 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return { activate: () => { + if (disposed) { + return () => {} + } const token = Symbol('status-feed') activations.add(token) if (activations.size === 1) { @@ -185,7 +197,15 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { listeners.add(listener) return () => listeners.delete(listener) }, - stop + stop, + invalidate: (removed) => { + stop() + disposed = removed + setSnapshot(new Map()) + if (!removed && activations.size > 0) { + open() + } + } } } @@ -201,6 +221,21 @@ export function getStructuredAgentSessionStatusFeed( return owner } +export function invalidateStructuredAgentSessionStatusFeed( + environmentId: string, + removed: boolean +): void { + const key = structuredAgentSessionStatusFeedKey({ kind: 'environment', environmentId }) + const owner = owners.get(key) + if (!owner) { + return + } + if (removed) { + owners.delete(key) + } + owner.invalidate(removed) +} + export function resetStructuredAgentSessionStatusFeedsForTests(): void { // Dropping the map alone leaves a live subscription and its pending reconnect running // into the next test, where they reopen a stream nothing is holding. diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index d4495ce7d8d..cf8a8f4fcef 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,3 +1,4 @@ +import { invalidateStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { RuntimeStatusSlice } from './runtime-status-types' @@ -76,6 +77,12 @@ export const createRuntimeStatusSlice: StateCreator environment.id) .filter((id) => !nextIds.has(id)) + for (const id of removedIds) { + invalidateStructuredAgentSessionStatusFeed(id, true) + } + for (const id of replacedEnvironmentIds) { + invalidateStructuredAgentSessionStatusFeed(id, false) + } runtimeStatusRecheck.cancelRuntimeStatusRechecks([...removedIds, ...replacedEnvironmentIds]) set((s) => { const keep = new Set(environments.map((environment) => environment.id)) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 70d464d5392..e10ec3c0697 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -208,10 +208,16 @@ export type AgentSessionStatusSummary = { updatedAt: number } -/** A summary outlives its provider child: an evicted idle session is still idle, so the host - * keeps the last projection and never retracts one. Tabs, not this feed, decide what is listed. */ +/** A summary outlives its provider child; the durable visible-tab catalog owns retraction. */ export type AgentSessionStatusEvent = - | { type: 'snapshot'; sessions: AgentSessionStatusSummary[] } + | { + type: 'snapshot' + sessions: AgentSessionStatusSummary[] + /** Absent on older hosts; only complete inventories authorize retraction. */ + catalog?: { epoch: string; complete: boolean; sessionIds: string[] } + /** Explicit tab removals remain authoritative during a partial restore. */ + removedSessionIds?: string[] + } | { type: 'status'; session: AgentSessionStatusSummary } | { type: 'end' }