@crazx/dsh-api-session-controller 0.1.2-alpha.3.zw.1
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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +74 -0
- package/README.zh.md +74 -0
- package/lib/client.js +2724 -0
- package/lib/index.js +2826 -0
- package/lib/invariant.js +13 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +2574 -0
- package/lib/typert.remote-client.d.ts +67 -0
- package/lib/typert.remote-client.js +1119 -0
- package/lib/types/agent.d.ts +156 -0
- package/lib/types/agent.js +537 -0
- package/lib/types/catalog.d.ts +11 -0
- package/lib/types/catalog.js +58 -0
- package/lib/types/client/contract/events.d.ts +71 -0
- package/lib/types/client/contract/events.js +91 -0
- package/lib/types/client/contract/session.d.ts +150 -0
- package/lib/types/client/contract/session.js +2 -0
- package/lib/types/client/contract/sessions.d.ts +125 -0
- package/lib/types/client/contract/sessions.js +2 -0
- package/lib/types/client/contract/snapshot.d.ts +82 -0
- package/lib/types/client/contract/snapshot.js +2 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/ordered-baseline.d.ts +12 -0
- package/lib/types/client/ordered-baseline.js +41 -0
- package/lib/types/client/scope.d.ts +36 -0
- package/lib/types/client/scope.js +54 -0
- package/lib/types/client/sessions/history-records.d.ts +22 -0
- package/lib/types/client/sessions/history-records.js +31 -0
- package/lib/types/client/sessions/lineage.d.ts +38 -0
- package/lib/types/client/sessions/lineage.js +56 -0
- package/lib/types/client/sessions/manager.d.ts +280 -0
- package/lib/types/client/sessions/manager.js +894 -0
- package/lib/types/client/sessions/notifier.d.ts +39 -0
- package/lib/types/client/sessions/notifier.js +98 -0
- package/lib/types/client/sessions/projection-store.d.ts +108 -0
- package/lib/types/client/sessions/projection-store.js +129 -0
- package/lib/types/client/sessions/queue-mirror.d.ts +26 -0
- package/lib/types/client/sessions/queue-mirror.js +61 -0
- package/lib/types/client/sessions/remotes.d.ts +30 -0
- package/lib/types/client/sessions/remotes.js +8 -0
- package/lib/types/client/sessions/service.d.ts +349 -0
- package/lib/types/client/sessions/service.js +574 -0
- package/lib/types/client/sessions/session.d.ts +294 -0
- package/lib/types/client/sessions/session.js +711 -0
- package/lib/types/client/time-zone.d.ts +8 -0
- package/lib/types/client/time-zone.js +14 -0
- package/lib/types/client/transport.d.ts +73 -0
- package/lib/types/client/transport.js +106 -0
- package/lib/types/commands.d.ts +69 -0
- package/lib/types/commands.js +544 -0
- package/lib/types/control.d.ts +23 -0
- package/lib/types/control.js +192 -0
- package/lib/types/file-references.d.ts +27 -0
- package/lib/types/file-references.js +69 -0
- package/lib/types/history.d.ts +31 -0
- package/lib/types/history.js +376 -0
- package/lib/types/index.d.ts +171 -0
- package/lib/types/index.js +424 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +12 -0
- package/lib/types/list.d.ts +53 -0
- package/lib/types/list.js +405 -0
- package/lib/types/model-selection-projection.d.ts +8 -0
- package/lib/types/model-selection-projection.js +66 -0
- package/lib/types/remote-events.d.ts +8 -0
- package/lib/types/remote-events.js +2 -0
- package/lib/types/skill-catalog.d.ts +28 -0
- package/lib/types/skill-catalog.js +192 -0
- package/lib/types/types.d.ts +505 -0
- package/lib/types/types.js +6 -0
- package/package.json +154 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
|
|
2
|
+
// dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
|
|
3
|
+
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
|
4
|
+
import { mergeOrderedBaseline } from "../ordered-baseline.js";
|
|
5
|
+
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
|
|
6
|
+
import { flattenLineage } from "./lineage.js";
|
|
7
|
+
import { Notifier } from "./notifier.js";
|
|
8
|
+
import { ProjectionValueStore } from "./projection-store.js";
|
|
9
|
+
import { Session } from "./session.js";
|
|
10
|
+
function catalogAvailability(parentAvailable) {
|
|
11
|
+
return parentAvailable === undefined ? {} : { parentAvailable };
|
|
12
|
+
}
|
|
13
|
+
/** Instance cluster + frame entry + the session list. */
|
|
14
|
+
export class SessionManager {
|
|
15
|
+
remote;
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
/** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
|
|
18
|
+
sessionDisposals = new Set();
|
|
19
|
+
/** Latest transient queues, retained independently of Session object materialization. */
|
|
20
|
+
queues = new Map();
|
|
21
|
+
/**
|
|
22
|
+
* Sessions that finished running while not selected — the sidebar's green
|
|
23
|
+
* "done" reminder (manager-owned, survives connection generations; cleared
|
|
24
|
+
* on select and session-removed, re-armed by the next completion).
|
|
25
|
+
*/
|
|
26
|
+
completedNotifications = new Set();
|
|
27
|
+
/** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
|
|
28
|
+
prevRunning = new Map();
|
|
29
|
+
/** Per-session projection value stores, retained independently of instance arrival (the
|
|
30
|
+
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
|
31
|
+
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
|
32
|
+
* same store so history-baseline seeding and frames converge on one row set. */
|
|
33
|
+
projectionStores = new Map();
|
|
34
|
+
summaries = [];
|
|
35
|
+
listState = 'idle';
|
|
36
|
+
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
|
37
|
+
listPhase = 'pending';
|
|
38
|
+
listError = null;
|
|
39
|
+
listInflight = null;
|
|
40
|
+
/** Mutations arriving after a list request starts are replayed over its response. */
|
|
41
|
+
listMutations = null;
|
|
42
|
+
addresses = new Map();
|
|
43
|
+
catalogs = new Map();
|
|
44
|
+
catalogInflight = new Map();
|
|
45
|
+
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
|
|
46
|
+
catalogStale = new Set();
|
|
47
|
+
openCatalogs = new Set();
|
|
48
|
+
catalogDebounce = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* Background jobs per session, last-wins from Session Controller's control
|
|
51
|
+
* stream. An empty set is stored as an absent key, so absence and `[]` are
|
|
52
|
+
* one representation.
|
|
53
|
+
*/
|
|
54
|
+
jobsBySession = new Map();
|
|
55
|
+
selected;
|
|
56
|
+
listSnapshotCache;
|
|
57
|
+
/** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
|
|
58
|
+
* object when every field matches — wire refreshes mint all-new summary objects, so identity
|
|
59
|
+
* must be recovered by value or every SessionListItem memo misses on every refresh. */
|
|
60
|
+
entryCache = new Map();
|
|
61
|
+
itemsCache = [];
|
|
62
|
+
notifier = new Notifier(() => {
|
|
63
|
+
this.listSnapshotCache = this.buildListSnapshot();
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* @param remote - generated Remote namespaces the Session cluster calls.
|
|
67
|
+
* @param restoredSelection - persisted real-Session selection candidate.
|
|
68
|
+
*/
|
|
69
|
+
constructor(remote, restoredSelection, restoredAddress) {
|
|
70
|
+
this.remote = remote;
|
|
71
|
+
this.selected = restoredSelection;
|
|
72
|
+
if (restoredAddress !== undefined)
|
|
73
|
+
this.addresses.set(restoredAddress.childSessionId, restoredAddress);
|
|
74
|
+
this.listSnapshotCache = this.buildListSnapshot();
|
|
75
|
+
}
|
|
76
|
+
// ---- Selection ----
|
|
77
|
+
/**
|
|
78
|
+
* Select a listed Session or a retained catalog-addressed child.
|
|
79
|
+
* @param sessionId - listed or catalog-addressed Session id.
|
|
80
|
+
*/
|
|
81
|
+
select(sessionId) {
|
|
82
|
+
const address = this.navigationAddress(sessionId);
|
|
83
|
+
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
|
|
84
|
+
throw new Error(`sessions.select: unknown session ${sessionId}`);
|
|
85
|
+
}
|
|
86
|
+
if (address !== undefined)
|
|
87
|
+
this.addresses.set(sessionId, address);
|
|
88
|
+
this.sessions.get(sessionId)?.configureSubagent(address, address === undefined
|
|
89
|
+
? undefined
|
|
90
|
+
: this.catalogs.get(address.parentSessionId)?.parentAvailable);
|
|
91
|
+
this.selected = sessionId;
|
|
92
|
+
// Looking at the session consumes its completion reminder (dot clears).
|
|
93
|
+
this.completedNotifications.delete(sessionId);
|
|
94
|
+
void this.refreshSubagents(sessionId);
|
|
95
|
+
this.notifier.notifyNow();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Select a healthy child through its durable direct-parent address.
|
|
99
|
+
* @param address - catalog-derived parent and child ids.
|
|
100
|
+
*/
|
|
101
|
+
selectSubagent(address) {
|
|
102
|
+
const catalog = this.catalogs.get(address.parentSessionId);
|
|
103
|
+
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId);
|
|
104
|
+
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
|
|
105
|
+
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`);
|
|
106
|
+
}
|
|
107
|
+
this.addresses.set(address.childSessionId, address);
|
|
108
|
+
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable);
|
|
109
|
+
this.selected = address.childSessionId;
|
|
110
|
+
this.completedNotifications.delete(address.childSessionId);
|
|
111
|
+
void this.refreshSubagents(address.childSessionId);
|
|
112
|
+
this.notifier.notifyNow();
|
|
113
|
+
}
|
|
114
|
+
/** Clear the selection (the layout falls to the no-session view state). */
|
|
115
|
+
clearSelection() {
|
|
116
|
+
this.selected = undefined;
|
|
117
|
+
this.notifier.notifyNow();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Return the durable catalog address retained for one child.
|
|
121
|
+
* @param sessionId - possible addressed child id.
|
|
122
|
+
* @returns The direct-parent address, when navigation discovered one.
|
|
123
|
+
*/
|
|
124
|
+
subagentAddress(sessionId) {
|
|
125
|
+
return this.addresses.get(sessionId);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Resolve an address for breadcrumb navigation without retaining transport authority.
|
|
129
|
+
* @param sessionId - possible child id in an already-loaded catalog.
|
|
130
|
+
* @returns A retained or catalog-derived direct-parent address.
|
|
131
|
+
*/
|
|
132
|
+
navigationAddress(sessionId) {
|
|
133
|
+
const retained = this.addresses.get(sessionId);
|
|
134
|
+
if (retained !== undefined)
|
|
135
|
+
return retained;
|
|
136
|
+
for (const [parentSessionId, catalog] of this.catalogs) {
|
|
137
|
+
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId);
|
|
138
|
+
if (child?.kind === 'child') {
|
|
139
|
+
return { parentSessionId, childSessionId: sessionId, mode: child.mode };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
// ---- Instance management ----
|
|
145
|
+
/**
|
|
146
|
+
* Drop a session instance (scope-prune companion: instance
|
|
147
|
+
* and scope share one lifecycle). The host session log is the durable
|
|
148
|
+
* truth — a later get() lazily rebuilds and open() backfills history.
|
|
149
|
+
* @param sessionId - the session to drop.
|
|
150
|
+
*/
|
|
151
|
+
async drop(sessionId) {
|
|
152
|
+
const session = this.sessions.get(sessionId);
|
|
153
|
+
this.sessions.delete(sessionId);
|
|
154
|
+
if (session !== undefined)
|
|
155
|
+
await this.startSessionDisposal(session);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Stop owned timers and every remaining Session instance.
|
|
159
|
+
* @returns when every Session Remote iterator has completed teardown.
|
|
160
|
+
*/
|
|
161
|
+
async dispose() {
|
|
162
|
+
for (const timer of this.catalogDebounce.values())
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
this.catalogDebounce.clear();
|
|
165
|
+
this.catalogStale.clear();
|
|
166
|
+
this.openCatalogs.clear();
|
|
167
|
+
const sessions = [...this.sessions.values()];
|
|
168
|
+
this.sessions.clear();
|
|
169
|
+
for (const session of sessions)
|
|
170
|
+
void this.startSessionDisposal(session);
|
|
171
|
+
await this.drainSessionDisposals();
|
|
172
|
+
}
|
|
173
|
+
startSessionDisposal(session) {
|
|
174
|
+
const disposal = session.dispose();
|
|
175
|
+
this.sessionDisposals.add(disposal);
|
|
176
|
+
void disposal.then(() => { this.sessionDisposals.delete(disposal); }, () => { this.sessionDisposals.delete(disposal); });
|
|
177
|
+
return disposal;
|
|
178
|
+
}
|
|
179
|
+
async drainSessionDisposals() {
|
|
180
|
+
while (this.sessionDisposals.size > 0) {
|
|
181
|
+
await Promise.allSettled([...this.sessionDisposals]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Lazy build: return the existing instance or construct one (no auto-open —
|
|
186
|
+
* open is triggered by the container's select callback).
|
|
187
|
+
* @param sessionId - the session to get.
|
|
188
|
+
* @returns the resident instance.
|
|
189
|
+
*/
|
|
190
|
+
get(sessionId) {
|
|
191
|
+
let session = this.sessions.get(sessionId);
|
|
192
|
+
if (session === undefined) {
|
|
193
|
+
session = this.createSession(sessionId);
|
|
194
|
+
this.sessions.set(sessionId, session);
|
|
195
|
+
// Install the latest control baseline before the running-bit sync: a
|
|
196
|
+
// not-running summary must sweep replayed queue
|
|
197
|
+
// rows the same way a live status flip would (their retirement events dropped
|
|
198
|
+
// while the session was uninstantiated).
|
|
199
|
+
session.replaceControl(this.queues.get(sessionId) ?? []);
|
|
200
|
+
// Sync the running and blank bits from the list snapshot into the new
|
|
201
|
+
// instance (consistency when the list precedes open).
|
|
202
|
+
const summary = this.summaries.find(s => s.sessionId === sessionId);
|
|
203
|
+
if (summary !== undefined) {
|
|
204
|
+
session.handleBlank(summary.blank);
|
|
205
|
+
session.handleRunning(summary.running);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const address = this.addresses.get(sessionId);
|
|
209
|
+
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
|
|
210
|
+
.find(entry => entry.kind === 'child' && entry.id === sessionId);
|
|
211
|
+
if (child?.kind === 'child') {
|
|
212
|
+
// A catalogued child exists only after its delegated session has
|
|
213
|
+
// durable history, even though child rows do not carry `blank`.
|
|
214
|
+
session.handleBlank(false);
|
|
215
|
+
session.handleRunning(child.activity === 'running');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return session;
|
|
220
|
+
}
|
|
221
|
+
createSession(sessionId) {
|
|
222
|
+
const address = this.addresses.get(sessionId);
|
|
223
|
+
const parentAvailable = address === undefined
|
|
224
|
+
? undefined
|
|
225
|
+
: this.catalogs.get(address.parentSessionId)?.parentAvailable;
|
|
226
|
+
return new Session(sessionId, this.remote, {
|
|
227
|
+
...(address === undefined ? {} : {
|
|
228
|
+
address,
|
|
229
|
+
...catalogAvailability(parentAvailable),
|
|
230
|
+
}),
|
|
231
|
+
// The sender's local first-send flip mirrors into the list row so the
|
|
232
|
+
// session surfaces (lists filter on blank) before any host frame lands.
|
|
233
|
+
onEngaged: (engaged) => {
|
|
234
|
+
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId });
|
|
235
|
+
},
|
|
236
|
+
projections: this.projectionStore(sessionId),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
|
240
|
+
projectionStore(sessionId) {
|
|
241
|
+
let store = this.projectionStores.get(sessionId);
|
|
242
|
+
if (store === undefined) {
|
|
243
|
+
store = new ProjectionValueStore();
|
|
244
|
+
// List rows project off store keys (title); any-key changes re-enter
|
|
245
|
+
// the manager's own batched rebuild channel.
|
|
246
|
+
store.subscribeAny(() => { this.notifier.markDirty(); });
|
|
247
|
+
this.projectionStores.set(sessionId, store);
|
|
248
|
+
}
|
|
249
|
+
return store;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Refresh one direct-child catalog, reusing its in-flight request.
|
|
253
|
+
* @param parentSessionId - catalog owner.
|
|
254
|
+
*/
|
|
255
|
+
refreshSubagents(parentSessionId) {
|
|
256
|
+
const existing = this.catalogInflight.get(parentSessionId);
|
|
257
|
+
if (existing !== undefined)
|
|
258
|
+
return existing.promise;
|
|
259
|
+
const previous = this.catalogs.get(parentSessionId);
|
|
260
|
+
const expandableRows = new Set();
|
|
261
|
+
const activityRows = new Map();
|
|
262
|
+
this.catalogs.set(parentSessionId, {
|
|
263
|
+
entries: previous?.entries ?? [],
|
|
264
|
+
...(previous?.parentAvailable === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { parentAvailable: previous.parentAvailable }),
|
|
267
|
+
state: 'loading',
|
|
268
|
+
error: null,
|
|
269
|
+
});
|
|
270
|
+
this.notifier.markDirty();
|
|
271
|
+
const operation = (async () => {
|
|
272
|
+
try {
|
|
273
|
+
const result = await this.remote.subagents.list(parentSessionId);
|
|
274
|
+
if (result.ok) {
|
|
275
|
+
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
276
|
+
?? result.value.parentAvailable;
|
|
277
|
+
this.catalogs.set(parentSessionId, {
|
|
278
|
+
...result.value,
|
|
279
|
+
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
|
|
280
|
+
parentAvailable,
|
|
281
|
+
state: 'ready',
|
|
282
|
+
error: null,
|
|
283
|
+
});
|
|
284
|
+
for (const [childId, address] of this.addresses) {
|
|
285
|
+
if (address.parentSessionId !== parentSessionId)
|
|
286
|
+
continue;
|
|
287
|
+
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
this.catalogs.set(parentSessionId, {
|
|
292
|
+
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
293
|
+
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
294
|
+
?? previous?.parentAvailable),
|
|
295
|
+
state: 'error',
|
|
296
|
+
error: result.error,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (!isRemoteFailure(error))
|
|
302
|
+
throw error;
|
|
303
|
+
this.catalogs.set(parentSessionId, {
|
|
304
|
+
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
305
|
+
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
306
|
+
?? previous?.parentAvailable),
|
|
307
|
+
state: 'error',
|
|
308
|
+
error,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
this.catalogInflight.delete(parentSessionId);
|
|
313
|
+
// Re-arm the trailing pull before the dirty notify: the response the
|
|
314
|
+
// caller observed predates the stale-marking change, so the follow-up
|
|
315
|
+
// refresh is the only carrier of that change.
|
|
316
|
+
if (this.catalogStale.delete(parentSessionId))
|
|
317
|
+
void this.refreshSubagents(parentSessionId);
|
|
318
|
+
this.notifier.markDirty();
|
|
319
|
+
}
|
|
320
|
+
})();
|
|
321
|
+
this.catalogInflight.set(parentSessionId, {
|
|
322
|
+
promise: operation,
|
|
323
|
+
expandableRows,
|
|
324
|
+
activityRows,
|
|
325
|
+
parentAvailableOverride: undefined,
|
|
326
|
+
});
|
|
327
|
+
return operation;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Mark whether a catalog menu is consuming live membership updates.
|
|
331
|
+
* @param parentSessionId - catalog owner.
|
|
332
|
+
* @param open - current menu state.
|
|
333
|
+
*/
|
|
334
|
+
setSubagentCatalogOpen(parentSessionId, open) {
|
|
335
|
+
if (open) {
|
|
336
|
+
this.openCatalogs.add(parentSessionId);
|
|
337
|
+
void this.refreshSubagents(parentSessionId);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
this.openCatalogs.delete(parentSessionId);
|
|
341
|
+
const timer = this.catalogDebounce.get(parentSessionId);
|
|
342
|
+
if (timer !== undefined) {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
this.catalogDebounce.delete(parentSessionId);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// ---- List API ----
|
|
349
|
+
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
|
350
|
+
refreshList() {
|
|
351
|
+
if (this.listInflight !== null)
|
|
352
|
+
return this.listInflight;
|
|
353
|
+
this.listState = 'loading';
|
|
354
|
+
this.listError = null;
|
|
355
|
+
const established = this.summaries;
|
|
356
|
+
const mutations = [];
|
|
357
|
+
this.listMutations = mutations;
|
|
358
|
+
this.notifier.markDirty();
|
|
359
|
+
this.listInflight = (async () => {
|
|
360
|
+
try {
|
|
361
|
+
const result = await this.remote.session.list({});
|
|
362
|
+
if (result.ok) {
|
|
363
|
+
const baseline = this.listPhase === 'pending'
|
|
364
|
+
? [...result.value.items]
|
|
365
|
+
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId);
|
|
366
|
+
// Seed first observations from the pull-time baseline BEFORE replaying
|
|
367
|
+
// in-flight mutations, then reconcile the reminders after EVERY
|
|
368
|
+
// replayed mutation: an edge that happens entirely between mutations
|
|
369
|
+
// (baseline idle → running → idle) must still arm, which a single
|
|
370
|
+
// sync on the folded result would collapse away.
|
|
371
|
+
for (const s of baseline) {
|
|
372
|
+
if (!this.prevRunning.has(s.sessionId))
|
|
373
|
+
this.prevRunning.set(s.sessionId, s.running);
|
|
374
|
+
}
|
|
375
|
+
let summaries = baseline;
|
|
376
|
+
for (const mutation of mutations) {
|
|
377
|
+
summaries = applyMutation(summaries, mutation);
|
|
378
|
+
this.summaries = summaries;
|
|
379
|
+
this.syncCompletedNotifications();
|
|
380
|
+
}
|
|
381
|
+
this.summaries = summaries;
|
|
382
|
+
this.listState = 'idle';
|
|
383
|
+
this.listPhase = 'ready';
|
|
384
|
+
// Covers the empty-mutations pull (a plain baseline carries no edge).
|
|
385
|
+
this.syncCompletedNotifications();
|
|
386
|
+
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
|
387
|
+
for (const s of this.summaries) {
|
|
388
|
+
const session = this.sessions.get(s.sessionId);
|
|
389
|
+
if (session === undefined)
|
|
390
|
+
continue;
|
|
391
|
+
session.handleBlank(s.blank);
|
|
392
|
+
session.handleRunning(s.running);
|
|
393
|
+
}
|
|
394
|
+
// Seed each row's projection baseline into the per-session value
|
|
395
|
+
// store (cold titles surface without opening the session). Per-key
|
|
396
|
+
// apply, not seed(): the list block is a partial baseline — the
|
|
397
|
+
// cold cache serves only version-matching keys — so an absent key
|
|
398
|
+
// must not clear; higher-seq-wins still keeps a stale list block
|
|
399
|
+
// from overwriting a newer push frame or tail baseline.
|
|
400
|
+
for (const s of result.value.items) {
|
|
401
|
+
const block = s.projections;
|
|
402
|
+
if (block === undefined)
|
|
403
|
+
continue;
|
|
404
|
+
const store = this.projectionStore(s.sessionId);
|
|
405
|
+
const values = block.values;
|
|
406
|
+
for (const key of Object.keys(values))
|
|
407
|
+
store.apply(key, values[key], block.asOfSeq);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
this.listState = 'error';
|
|
412
|
+
this.listError = result.error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (!isRemoteFailure(error))
|
|
417
|
+
throw error;
|
|
418
|
+
this.listState = 'error';
|
|
419
|
+
this.listError = error;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
this.listMutations = null;
|
|
423
|
+
this.listInflight = null;
|
|
424
|
+
this.notifier.markDirty();
|
|
425
|
+
}
|
|
426
|
+
})();
|
|
427
|
+
return this.listInflight;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Search visible session message content without adding transient query
|
|
431
|
+
* state to the list snapshot.
|
|
432
|
+
* @param query - non-blank literal phrase.
|
|
433
|
+
* @param signal - cancellation for superseded UI queries.
|
|
434
|
+
* @returns the Host result or a folded transport error.
|
|
435
|
+
*/
|
|
436
|
+
async search(query, signal) {
|
|
437
|
+
const result = await this.remote.session.search({ query }, signal);
|
|
438
|
+
if (!result.ok)
|
|
439
|
+
return result;
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
value: {
|
|
443
|
+
items: [...result.value.items],
|
|
444
|
+
hasMore: result.value.hasMore,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Contract session.create; on success merge into summaries immediately (no
|
|
450
|
+
* wait for the next refresh). A created session is blank by definition
|
|
451
|
+
* (entity birth precedes the first message).
|
|
452
|
+
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
|
453
|
+
* @returns the create result.
|
|
454
|
+
*/
|
|
455
|
+
async create(opts = {}) {
|
|
456
|
+
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId };
|
|
457
|
+
const payload = opts.workspaceId !== undefined
|
|
458
|
+
? { workspaceId: opts.workspaceId, ...shared }
|
|
459
|
+
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared };
|
|
460
|
+
const result = await this.remote.session.create(payload);
|
|
461
|
+
if (result.ok) {
|
|
462
|
+
this.recordMutation({ kind: 'upsert', summary: {
|
|
463
|
+
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
|
464
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
465
|
+
} });
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const publishedSessionId = workspaceAttachSessionId(result.error);
|
|
469
|
+
// Publication precedes attachment. The error's id is a real Session,
|
|
470
|
+
// so expose it immediately as Ungrouped while the caller keeps the
|
|
471
|
+
// prompt buffer and decides whether to retry attachment.
|
|
472
|
+
if (publishedSessionId !== undefined) {
|
|
473
|
+
this.recordMutation({ kind: 'upsert', summary: {
|
|
474
|
+
sessionId: publishedSessionId,
|
|
475
|
+
updatedAt: Date.now(),
|
|
476
|
+
running: false,
|
|
477
|
+
blank: true,
|
|
478
|
+
} });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Contract session.fork; on success merge the child into summaries
|
|
485
|
+
* immediately (same synchronous-addressability guarantee as create). The
|
|
486
|
+
* child carries the source's history, so it is never blank; lineage rides
|
|
487
|
+
* parentSessionId so the list nests it under its source. A child published
|
|
488
|
+
* before Workspace attachment fails is also reconciled into the list.
|
|
489
|
+
* @param opts - source session and the optional seq anchoring the cut.
|
|
490
|
+
* @returns the fork result (the child session id).
|
|
491
|
+
*/
|
|
492
|
+
async fork(opts) {
|
|
493
|
+
const source = this.summaries.find(s => s.sessionId === opts.sessionId);
|
|
494
|
+
const result = await this.remote.session.fork({
|
|
495
|
+
sessionId: opts.sessionId,
|
|
496
|
+
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
|
|
497
|
+
});
|
|
498
|
+
const childId = result.ok
|
|
499
|
+
? result.value.sessionId
|
|
500
|
+
: workspaceAttachSessionId(result.error);
|
|
501
|
+
if (childId !== undefined) {
|
|
502
|
+
this.recordMutation({ kind: 'upsert', summary: {
|
|
503
|
+
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
|
|
504
|
+
parentSessionId: opts.sessionId,
|
|
505
|
+
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
|
506
|
+
} });
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
|
512
|
+
* existing entry only gains fields it lacks (the session-added frame and the
|
|
513
|
+
* create() echo race — whichever lands second must fill the placeholder's
|
|
514
|
+
* missing cwd/parentSessionId, never overwrite list-refresh data).
|
|
515
|
+
*/
|
|
516
|
+
mergeSummary(summary) {
|
|
517
|
+
this.recordMutation({ kind: 'upsert', summary });
|
|
518
|
+
}
|
|
519
|
+
/** Apply immediately and retain for replay when a list response is in flight. */
|
|
520
|
+
recordMutation(mutation) {
|
|
521
|
+
this.listMutations?.push(mutation);
|
|
522
|
+
this.summaries = applyMutation(this.summaries, mutation);
|
|
523
|
+
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
|
|
524
|
+
this.syncCompletedNotifications();
|
|
525
|
+
this.notifier.markDirty();
|
|
526
|
+
}
|
|
527
|
+
// ---- Subscription API (for useSessionList) ----
|
|
528
|
+
/**
|
|
529
|
+
* uSES subscription entry for useSessionList.
|
|
530
|
+
* @param listener - change callback.
|
|
531
|
+
* @returns the unsubscribe function.
|
|
532
|
+
*/
|
|
533
|
+
subscribe(listener) {
|
|
534
|
+
return this.notifier.subscribe(listener);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Cached list snapshot (rebuilt lazily when dirty with no listeners).
|
|
538
|
+
* @returns the cached reference (stable until the next flush).
|
|
539
|
+
*/
|
|
540
|
+
getListSnapshot() {
|
|
541
|
+
this.notifier.ensureFresh();
|
|
542
|
+
return this.listSnapshotCache;
|
|
543
|
+
}
|
|
544
|
+
// ---- Live control and Host-event sinks ----
|
|
545
|
+
/**
|
|
546
|
+
* Apply a complete control baseline or one later replacement frame.
|
|
547
|
+
* @param frame - baseline or live control replacement from Session Controller.
|
|
548
|
+
*/
|
|
549
|
+
handleControlFrame(frame) {
|
|
550
|
+
if (frame.type === 'baseline') {
|
|
551
|
+
this.replaceControlBaseline(frame.value);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (frame.type === 'projection') {
|
|
555
|
+
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq);
|
|
556
|
+
this.notifier.markDirty();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (frame.type === 'jobs') {
|
|
560
|
+
if (frame.jobs.length === 0)
|
|
561
|
+
this.jobsBySession.delete(frame.sessionId);
|
|
562
|
+
else
|
|
563
|
+
this.jobsBySession.set(frame.sessionId, frame.jobs);
|
|
564
|
+
this.notifier.markDirty();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
this.queues.set(frame.sessionId, frame.items);
|
|
568
|
+
this.sessions.get(frame.sessionId)?.handleControlFrame(frame);
|
|
569
|
+
}
|
|
570
|
+
replaceControlBaseline(baseline) {
|
|
571
|
+
this.queues.clear();
|
|
572
|
+
for (const [sessionId, items] of Object.entries(baseline.queues)) {
|
|
573
|
+
this.queues.set(sessionId, items);
|
|
574
|
+
}
|
|
575
|
+
this.jobsBySession.clear();
|
|
576
|
+
for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
|
|
577
|
+
if (jobs.length > 0)
|
|
578
|
+
this.jobsBySession.set(sessionId, jobs);
|
|
579
|
+
}
|
|
580
|
+
for (const [sessionId, block] of Object.entries(baseline.projections)) {
|
|
581
|
+
const store = this.projectionStore(sessionId);
|
|
582
|
+
store.truncate(block.asOfSeq);
|
|
583
|
+
store.seed(block);
|
|
584
|
+
}
|
|
585
|
+
for (const [sessionId, session] of this.sessions) {
|
|
586
|
+
session.replaceControl(this.queues.get(sessionId) ?? []);
|
|
587
|
+
}
|
|
588
|
+
this.notifier.markDirty();
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Apply one Session-list addition forwarded through `ctx.remote.$on`.
|
|
592
|
+
* @param summary - current Host summary for the added Session.
|
|
593
|
+
*/
|
|
594
|
+
handleSessionAdded(summary) {
|
|
595
|
+
this.mergeSummary(summary);
|
|
596
|
+
this.sessions.get(summary.sessionId)?.handleBlank(summary.blank);
|
|
597
|
+
const projections = summary.projections;
|
|
598
|
+
if (projections !== undefined) {
|
|
599
|
+
const store = this.projectionStore(summary.sessionId);
|
|
600
|
+
for (const [key, value] of Object.entries(projections.values)) {
|
|
601
|
+
store.apply(key, value, projections.asOfSeq);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
|
|
605
|
+
this.markCatalogParentExpandable(summary.parentSessionId);
|
|
606
|
+
}
|
|
607
|
+
if (summary.parentSessionId !== undefined
|
|
608
|
+
&& (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
|
|
609
|
+
this.scheduleCatalogRefresh(summary.parentSessionId);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Apply one Session removal forwarded through `ctx.remote.$on`.
|
|
614
|
+
* @param sessionId - removed Session identity.
|
|
615
|
+
*/
|
|
616
|
+
handleSessionRemoved(sessionId) {
|
|
617
|
+
const summary = this.summaries.find(candidate => candidate.sessionId === sessionId);
|
|
618
|
+
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId);
|
|
619
|
+
this.recordMutation(durableSubagent
|
|
620
|
+
? { kind: 'status', sessionId, running: false }
|
|
621
|
+
: { kind: 'remove', sessionId });
|
|
622
|
+
this.updateCatalogActivity(sessionId, false);
|
|
623
|
+
if (durableSubagent)
|
|
624
|
+
this.sessions.get(sessionId)?.handleRunning(false);
|
|
625
|
+
else
|
|
626
|
+
this.sessions.get(sessionId)?.handleRemoved();
|
|
627
|
+
this.queues.delete(sessionId);
|
|
628
|
+
this.jobsBySession.delete(sessionId);
|
|
629
|
+
if (!durableSubagent)
|
|
630
|
+
this.projectionStores.delete(sessionId);
|
|
631
|
+
const inflightCatalog = this.catalogInflight.get(sessionId);
|
|
632
|
+
if (inflightCatalog !== undefined) {
|
|
633
|
+
inflightCatalog.parentAvailableOverride = false;
|
|
634
|
+
this.catalogStale.add(sessionId);
|
|
635
|
+
}
|
|
636
|
+
const ownedCatalog = this.catalogs.get(sessionId);
|
|
637
|
+
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
|
|
638
|
+
this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false });
|
|
639
|
+
}
|
|
640
|
+
for (const [childId, address] of this.addresses) {
|
|
641
|
+
if (address.parentSessionId === sessionId) {
|
|
642
|
+
this.sessions.get(childId)?.handleSubagentParentAvailable(false);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Apply one live Agent running-state change.
|
|
648
|
+
* @param sessionId - Session whose Agent state changed.
|
|
649
|
+
* @param running - current Agent running state.
|
|
650
|
+
*/
|
|
651
|
+
handleSessionStatus(sessionId, running) {
|
|
652
|
+
this.recordMutation({ kind: 'status', sessionId, running });
|
|
653
|
+
this.sessions.get(sessionId)?.handleRunning(running);
|
|
654
|
+
this.updateCatalogActivity(sessionId, running);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Advance Session-list activity from one user-authored durable message.
|
|
658
|
+
* @param sessionId - Session whose activity changed.
|
|
659
|
+
* @param updatedAt - durable message timestamp.
|
|
660
|
+
*/
|
|
661
|
+
handleSessionActivity(sessionId, updatedAt) {
|
|
662
|
+
this.recordMutation({ kind: 'activity', sessionId, updatedAt });
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Surface one live Agent failure on an already-materialized Session.
|
|
666
|
+
* @param sessionId - Session whose Agent failed.
|
|
667
|
+
* @param message - caller-visible failure description.
|
|
668
|
+
*/
|
|
669
|
+
handleSessionError(sessionId, message) {
|
|
670
|
+
this.sessions.get(sessionId)?.handleAgentError(message);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Repair one re-established Host-event generation with queryable baselines.
|
|
674
|
+
* Opened Session follow streams resume independently through API Gateway.
|
|
675
|
+
*/
|
|
676
|
+
handleConnected() {
|
|
677
|
+
void this.refreshList();
|
|
678
|
+
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected);
|
|
679
|
+
if (selectedAddress !== undefined)
|
|
680
|
+
void this.refreshSubagents(selectedAddress.parentSessionId);
|
|
681
|
+
if (this.selected !== undefined)
|
|
682
|
+
void this.refreshSubagents(this.selected);
|
|
683
|
+
for (const parentSessionId of this.openCatalogs)
|
|
684
|
+
void this.refreshSubagents(parentSessionId);
|
|
685
|
+
}
|
|
686
|
+
/** Debounce membership refetches while one parent catalog is selected or open. */
|
|
687
|
+
scheduleCatalogRefresh(parentSessionId) {
|
|
688
|
+
if (this.catalogDebounce.has(parentSessionId))
|
|
689
|
+
return;
|
|
690
|
+
const timer = setTimeout(() => {
|
|
691
|
+
this.catalogDebounce.delete(parentSessionId);
|
|
692
|
+
// The in-flight response predates the membership frame that scheduled
|
|
693
|
+
// this callback. Queue one post-settlement pull instead of treating an
|
|
694
|
+
// ordinary overlapping read as evidence that catalog membership changed.
|
|
695
|
+
if (this.catalogInflight.has(parentSessionId)) {
|
|
696
|
+
this.catalogStale.add(parentSessionId);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
void this.refreshSubagents(parentSessionId);
|
|
700
|
+
}, 50);
|
|
701
|
+
this.catalogDebounce.set(parentSessionId, timer);
|
|
702
|
+
}
|
|
703
|
+
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
|
|
704
|
+
updateCatalogActivity(childSessionId, running) {
|
|
705
|
+
const activity = running ? 'running' : 'inactive';
|
|
706
|
+
for (const inflight of this.catalogInflight.values()) {
|
|
707
|
+
inflight.activityRows.set(childSessionId, activity);
|
|
708
|
+
}
|
|
709
|
+
let changed = false;
|
|
710
|
+
for (const [parentSessionId, catalog] of this.catalogs) {
|
|
711
|
+
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity))
|
|
712
|
+
continue;
|
|
713
|
+
const entries = catalog.entries.map((entry) => {
|
|
714
|
+
if (entry.kind !== 'child' || entry.id !== childSessionId)
|
|
715
|
+
return entry;
|
|
716
|
+
return { ...entry, activity };
|
|
717
|
+
});
|
|
718
|
+
changed = true;
|
|
719
|
+
this.catalogs.set(parentSessionId, { ...catalog, entries });
|
|
720
|
+
}
|
|
721
|
+
if (changed)
|
|
722
|
+
this.notifier.markDirty();
|
|
723
|
+
}
|
|
724
|
+
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
|
|
725
|
+
markCatalogParentExpandable(parentSessionId) {
|
|
726
|
+
this.applyCatalogParentExpandable(parentSessionId);
|
|
727
|
+
for (const inflight of this.catalogInflight.values())
|
|
728
|
+
inflight.expandableRows.add(parentSessionId);
|
|
729
|
+
}
|
|
730
|
+
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
|
|
731
|
+
applyCatalogParentExpandable(parentSessionId) {
|
|
732
|
+
let changed = false;
|
|
733
|
+
for (const [catalogParentId, catalog] of this.catalogs) {
|
|
734
|
+
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren))
|
|
735
|
+
continue;
|
|
736
|
+
const entries = catalog.entries.map((entry) => {
|
|
737
|
+
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren)
|
|
738
|
+
return entry;
|
|
739
|
+
return { ...entry, hasChildren: true };
|
|
740
|
+
});
|
|
741
|
+
changed = true;
|
|
742
|
+
this.catalogs.set(catalogParentId, { ...catalog, entries });
|
|
743
|
+
}
|
|
744
|
+
if (changed)
|
|
745
|
+
this.notifier.markDirty();
|
|
746
|
+
}
|
|
747
|
+
/** Fold request-local row mutations into one catalog result before publication. */
|
|
748
|
+
withCatalogMutations(entries, expandableRows, activityRows) {
|
|
749
|
+
return entries.map((entry) => {
|
|
750
|
+
if (entry.kind !== 'child')
|
|
751
|
+
return entry;
|
|
752
|
+
const activity = activityRows.get(entry.id);
|
|
753
|
+
if (!expandableRows.has(entry.id) && activity === undefined)
|
|
754
|
+
return entry;
|
|
755
|
+
return {
|
|
756
|
+
...entry,
|
|
757
|
+
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
|
|
758
|
+
...activity === undefined ? {} : { activity },
|
|
759
|
+
};
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Reconcile completion reminders against the latest summaries, eagerly after
|
|
764
|
+
* every mutation and pull (a snapshot-build-time pass would collapse
|
|
765
|
+
* consecutive status frames into one observation). A running→idle edge of a
|
|
766
|
+
* non-selected session arms its reminder; running disarms it; removal drops
|
|
767
|
+
* it. First observation only records the running bit — sessions already
|
|
768
|
+
* idle at load get no reminder.
|
|
769
|
+
*/
|
|
770
|
+
syncCompletedNotifications() {
|
|
771
|
+
const seen = new Set();
|
|
772
|
+
for (const s of this.summaries) {
|
|
773
|
+
seen.add(s.sessionId);
|
|
774
|
+
const prev = this.prevRunning.get(s.sessionId);
|
|
775
|
+
if (prev === undefined) {
|
|
776
|
+
this.prevRunning.set(s.sessionId, s.running);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (prev && !s.running) {
|
|
780
|
+
if (s.sessionId !== this.selected)
|
|
781
|
+
this.completedNotifications.add(s.sessionId);
|
|
782
|
+
}
|
|
783
|
+
else if (s.running) {
|
|
784
|
+
this.completedNotifications.delete(s.sessionId);
|
|
785
|
+
}
|
|
786
|
+
this.prevRunning.set(s.sessionId, s.running);
|
|
787
|
+
}
|
|
788
|
+
for (const id of this.prevRunning.keys()) {
|
|
789
|
+
if (!seen.has(id))
|
|
790
|
+
this.prevRunning.delete(id);
|
|
791
|
+
}
|
|
792
|
+
for (const id of this.completedNotifications) {
|
|
793
|
+
if (!seen.has(id))
|
|
794
|
+
this.completedNotifications.delete(id);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
buildListSnapshot() {
|
|
798
|
+
const merged = this.summaries.map((summary) => {
|
|
799
|
+
// List rows read the generic 'title' projection key (host-computed unit
|
|
800
|
+
// value; there is no dedicated title frame).
|
|
801
|
+
const projectionStore = this.projectionStores.get(summary.sessionId);
|
|
802
|
+
const title = projectionStore?.get('title');
|
|
803
|
+
const projectionValues = projectionStore?.values();
|
|
804
|
+
return {
|
|
805
|
+
...summary,
|
|
806
|
+
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
|
807
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
const fresh = flattenLineage(merged, this.completedNotifications);
|
|
811
|
+
const items = fresh.map((entry) => {
|
|
812
|
+
const prev = this.entryCache.get(entry.sessionId);
|
|
813
|
+
if (prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
|
814
|
+
&& prev.blank === entry.blank
|
|
815
|
+
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
|
816
|
+
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
|
817
|
+
&& prev.projectionValues === entry.projectionValues
|
|
818
|
+
&& prev.completed === entry.completed)
|
|
819
|
+
return prev;
|
|
820
|
+
this.entryCache.set(entry.sessionId, entry);
|
|
821
|
+
return entry;
|
|
822
|
+
});
|
|
823
|
+
for (const id of this.entryCache.keys()) {
|
|
824
|
+
if (!items.some(e => e.sessionId === id))
|
|
825
|
+
this.entryCache.delete(id);
|
|
826
|
+
}
|
|
827
|
+
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]);
|
|
828
|
+
if (!sameOrder)
|
|
829
|
+
this.itemsCache = items;
|
|
830
|
+
const selected = this.selected;
|
|
831
|
+
const current = selected !== undefined
|
|
832
|
+
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
|
|
833
|
+
? selected
|
|
834
|
+
: undefined;
|
|
835
|
+
return {
|
|
836
|
+
items: this.itemsCache,
|
|
837
|
+
current,
|
|
838
|
+
state: this.listState,
|
|
839
|
+
phase: this.listPhase,
|
|
840
|
+
error: this.listError,
|
|
841
|
+
subagentsByParent: Object.fromEntries(this.catalogs),
|
|
842
|
+
jobsBySession: Object.fromEntries(this.jobsBySession),
|
|
843
|
+
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Apply one list mutation without deriving display order. */
|
|
848
|
+
function applyMutation(summaries, mutation) {
|
|
849
|
+
switch (mutation.kind) {
|
|
850
|
+
case 'upsert': {
|
|
851
|
+
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId);
|
|
852
|
+
if (existing === undefined)
|
|
853
|
+
return [mutation.summary, ...summaries];
|
|
854
|
+
const filled = {
|
|
855
|
+
...existing,
|
|
856
|
+
// Blank only lowers: a stale true (session-added racing the local
|
|
857
|
+
// first send) never re-hides an already-surfaced session.
|
|
858
|
+
blank: existing.blank && mutation.summary.blank,
|
|
859
|
+
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
|
860
|
+
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
|
861
|
+
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
|
862
|
+
...(existing.origin === undefined && mutation.summary.origin !== undefined
|
|
863
|
+
? { origin: mutation.summary.origin } : {}),
|
|
864
|
+
};
|
|
865
|
+
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
|
866
|
+
&& filled.origin === existing.origin && filled.blank === existing.blank)
|
|
867
|
+
return [...summaries];
|
|
868
|
+
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary);
|
|
869
|
+
}
|
|
870
|
+
case 'remove':
|
|
871
|
+
return summaries.filter(summary => summary.sessionId !== mutation.sessionId);
|
|
872
|
+
case 'status':
|
|
873
|
+
// running:true doubles as the cross-client blank flip (a blank session
|
|
874
|
+
// never runs, so the first running frame proves a message landed).
|
|
875
|
+
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
|
876
|
+
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
|
|
877
|
+
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
|
|
878
|
+
: summary);
|
|
879
|
+
case 'activity':
|
|
880
|
+
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
|
881
|
+
&& mutation.updatedAt > summary.updatedAt
|
|
882
|
+
? { ...summary, updatedAt: mutation.updatedAt }
|
|
883
|
+
: summary);
|
|
884
|
+
case 'engaged':
|
|
885
|
+
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
|
|
886
|
+
? { ...summary, blank: false }
|
|
887
|
+
: summary);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/** Temporary source-plane bridge while the Host contract and client project build independently. */
|
|
891
|
+
function workspaceAttachSessionId(error) {
|
|
892
|
+
return error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined;
|
|
893
|
+
}
|
|
894
|
+
//# sourceMappingURL=manager.js.map
|