@deepseek-ai/dsh-api-session-controller 0.1.6-alpha.1 → 0.1.7-alpha.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/README.i18n.yaml +2 -2
- package/README.md +42 -7
- package/README.zh.md +42 -7
- package/lib/client.js +1037 -992
- package/lib/index.js +314 -183
- package/lib/typert.host.js +539 -331
- package/lib/typert.remote-client.d.ts +10 -5
- package/lib/typert.remote-client.js +374 -264
- package/lib/types/agent.d.ts +1 -1
- package/lib/types/agent.js +8 -1
- package/lib/types/archived-session-gate.d.ts +30 -0
- package/lib/types/archived-session-gate.js +52 -0
- package/lib/types/client/contract/session.d.ts +2 -0
- package/lib/types/client/contract/sessions.d.ts +65 -35
- package/lib/types/client/contract/snapshot.d.ts +0 -14
- package/lib/types/client/index.d.ts +12 -3
- package/lib/types/client/index.js +16 -5
- package/lib/types/client/scope.d.ts +13 -3
- package/lib/types/client/scope.js +15 -22
- package/lib/types/client/sessions/lineage.d.ts +5 -5
- package/lib/types/client/sessions/lineage.js +3 -4
- package/lib/types/client/sessions/manager.d.ts +91 -105
- package/lib/types/client/sessions/manager.js +307 -434
- package/lib/types/client/sessions/projection-store.d.ts +34 -21
- package/lib/types/client/sessions/projection-store.js +48 -24
- package/lib/types/client/sessions/remotes.d.ts +1 -2
- package/lib/types/client/sessions/service.d.ts +53 -150
- package/lib/types/client/sessions/service.js +267 -264
- package/lib/types/client/sessions/session.d.ts +24 -35
- package/lib/types/client/sessions/session.js +131 -73
- package/lib/types/commands.d.ts +6 -4
- package/lib/types/commands.js +82 -40
- package/lib/types/control.d.ts +2 -4
- package/lib/types/control.js +2 -84
- package/lib/types/history.js +1 -1
- package/lib/types/index.d.ts +34 -7
- package/lib/types/index.js +118 -14
- package/lib/types/list.js +24 -15
- package/lib/types/skill-catalog.js +60 -50
- package/lib/types/types.d.ts +40 -40
- package/lib/types/types.js +0 -1
- package/package.json +71 -76
- package/lib/types/client/sessions/queue-mirror.d.ts +0 -26
- package/lib/types/client/sessions/queue-mirror.js +0 -61
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
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.
|
|
1
|
+
/** Host catalog, durable projection caches, and explicitly retained Client instances. */
|
|
4
2
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types';
|
|
5
3
|
import { mergeOrderedBaseline } from "../ordered-baseline.js";
|
|
6
4
|
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
|
|
5
|
+
import { assertNever } from '@deepseek-ai/dsh-util-values';
|
|
7
6
|
import { flattenLineage } from "./lineage.js";
|
|
8
7
|
import { Notifier } from "./notifier.js";
|
|
9
8
|
import { ProjectionValueStore } from "./projection-store.js";
|
|
@@ -11,25 +10,21 @@ import { Session } from "./session.js";
|
|
|
11
10
|
function sessionSeqCursor(value) {
|
|
12
11
|
return value === -1 ? -1 : SessionSeq(value);
|
|
13
12
|
}
|
|
14
|
-
function catalogAvailability(parentAvailable) {
|
|
15
|
-
return parentAvailable === undefined ? {} : { parentAvailable };
|
|
16
|
-
}
|
|
17
13
|
/** Instance cluster + frame entry + the session list. */
|
|
18
14
|
export class SessionManager {
|
|
19
15
|
remote;
|
|
20
16
|
sessions = new Map();
|
|
21
17
|
/** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
|
|
22
18
|
sessionDisposals = new Set();
|
|
23
|
-
/** Latest transient queues, retained independently of Session object materialization. */
|
|
24
|
-
queues = new Map();
|
|
25
19
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
20
|
+
* Accepted/running presentation must survive a later empty-history list
|
|
21
|
+
* response. Host-asserted running is recorded even before a row, instance, or
|
|
22
|
+
* address holds the identity — the listing that would hold it may not have
|
|
23
|
+
* landed yet — while the client-local acceptance callback requires a current
|
|
24
|
+
* holder, because it can arrive from a replaced or already-dropped Session.
|
|
29
25
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
prevRunning = new Map();
|
|
26
|
+
engagedSessions = new Set();
|
|
27
|
+
disposed = false;
|
|
33
28
|
/** Per-session projection value stores, retained independently of instance arrival (the
|
|
34
29
|
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
|
35
30
|
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
|
@@ -41,22 +36,11 @@ export class SessionManager {
|
|
|
41
36
|
listPhase = 'pending';
|
|
42
37
|
listError = null;
|
|
43
38
|
listInflight = null;
|
|
44
|
-
/**
|
|
39
|
+
/** Active list request's mutation log; its identity also fences completion after reconnect. */
|
|
45
40
|
listMutations = null;
|
|
46
41
|
addresses = new Map();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
|
|
50
|
-
catalogStale = new Set();
|
|
51
|
-
openCatalogs = new Set();
|
|
52
|
-
catalogDebounce = new Map();
|
|
53
|
-
/**
|
|
54
|
-
* Background jobs per session, last-wins from Session Controller's control
|
|
55
|
-
* stream. An empty set is stored as an absent key, so absence and `[]` are
|
|
56
|
-
* one representation.
|
|
57
|
-
*/
|
|
58
|
-
jobsBySession = new Map();
|
|
59
|
-
selected;
|
|
42
|
+
projectionLoads = new Map();
|
|
43
|
+
projectionInflight = new Map();
|
|
60
44
|
listSnapshotCache;
|
|
61
45
|
/** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
|
|
62
46
|
* object when every field matches — wire refreshes mint all-new summary objects, so identity
|
|
@@ -66,110 +50,87 @@ export class SessionManager {
|
|
|
66
50
|
notifier = new Notifier(() => {
|
|
67
51
|
this.listSnapshotCache = this.buildListSnapshot();
|
|
68
52
|
});
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
* @param restoredSelection - persisted real-Session selection candidate.
|
|
72
|
-
*/
|
|
73
|
-
constructor(remote, restoredSelection, restoredAddress) {
|
|
53
|
+
/** @param remote - generated Remote namespaces used by catalog and history readers. */
|
|
54
|
+
constructor(remote) {
|
|
74
55
|
this.remote = remote;
|
|
75
|
-
this.selected = restoredSelection;
|
|
76
|
-
if (restoredAddress !== undefined)
|
|
77
|
-
this.addresses.set(restoredAddress.childSessionId, restoredAddress);
|
|
78
56
|
this.listSnapshotCache = this.buildListSnapshot();
|
|
79
57
|
}
|
|
80
|
-
// ---- Selection ----
|
|
81
58
|
/**
|
|
82
|
-
*
|
|
83
|
-
* @param
|
|
59
|
+
* Resolve an acquisition target without materializing a Session.
|
|
60
|
+
* @param target - known identity or durable direct-parent address.
|
|
61
|
+
* @returns the resolved identity with its explicit or catalog-derived history route installed.
|
|
84
62
|
*/
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
63
|
+
resolveTarget(target) {
|
|
64
|
+
const id = typeof target === 'string' ? target : target.childSessionId;
|
|
65
|
+
const address = typeof target === 'string' ? this.subagentAddress(id) : target;
|
|
66
|
+
if (typeof target === 'string'
|
|
67
|
+
&& !this.sessions.has(id)
|
|
68
|
+
&& !this.summaries.some(summary => summary.sessionId === id)
|
|
69
|
+
&& address === undefined) {
|
|
70
|
+
throw new Error(`sessions.retain: unknown session ${id}`);
|
|
89
71
|
}
|
|
90
72
|
if (address !== undefined)
|
|
91
|
-
this.addresses.set(
|
|
92
|
-
this.sessions.get(
|
|
73
|
+
this.addresses.set(id, address);
|
|
74
|
+
this.sessions.get(id)?.configureSubagent(address, address === undefined
|
|
93
75
|
? undefined
|
|
94
|
-
: this.
|
|
95
|
-
|
|
96
|
-
// Looking at the session consumes its completion reminder (dot clears).
|
|
97
|
-
this.completedNotifications.delete(sessionId);
|
|
98
|
-
void this.refreshSubagents(sessionId);
|
|
99
|
-
this.notifier.notifyNow();
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Select a healthy child through its durable direct-parent address.
|
|
103
|
-
* @param address - catalog-derived parent and child ids.
|
|
104
|
-
*/
|
|
105
|
-
selectSubagent(address) {
|
|
106
|
-
const catalog = this.catalogs.get(address.parentSessionId);
|
|
107
|
-
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId);
|
|
108
|
-
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
|
|
109
|
-
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`);
|
|
110
|
-
}
|
|
111
|
-
this.addresses.set(address.childSessionId, address);
|
|
112
|
-
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable);
|
|
113
|
-
this.selected = address.childSessionId;
|
|
114
|
-
this.completedNotifications.delete(address.childSessionId);
|
|
115
|
-
void this.refreshSubagents(address.childSessionId);
|
|
116
|
-
this.notifier.notifyNow();
|
|
117
|
-
}
|
|
118
|
-
/** Clear the selection (the layout falls to the no-session view state). */
|
|
119
|
-
clearSelection() {
|
|
120
|
-
this.selected = undefined;
|
|
121
|
-
this.notifier.notifyNow();
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Return the durable catalog address retained for one child.
|
|
125
|
-
* @param sessionId - possible addressed child id.
|
|
126
|
-
* @returns The direct-parent address, when navigation discovered one.
|
|
127
|
-
*/
|
|
128
|
-
subagentAddress(sessionId) {
|
|
129
|
-
return this.addresses.get(sessionId);
|
|
76
|
+
: this.agentAvailable(address.parentSessionId));
|
|
77
|
+
return id;
|
|
130
78
|
}
|
|
131
79
|
/**
|
|
132
80
|
* Resolve an address for breadcrumb navigation without retaining transport authority.
|
|
133
81
|
* @param sessionId - possible child id in an already-loaded catalog.
|
|
134
82
|
* @returns A retained or catalog-derived direct-parent address.
|
|
135
83
|
*/
|
|
136
|
-
|
|
84
|
+
subagentAddress(sessionId) {
|
|
137
85
|
const retained = this.addresses.get(sessionId);
|
|
138
86
|
if (retained !== undefined)
|
|
139
87
|
return retained;
|
|
140
|
-
for (const
|
|
141
|
-
const child =
|
|
142
|
-
|
|
143
|
-
|
|
88
|
+
for (const parentSessionId of this.projectionStores.keys()) {
|
|
89
|
+
const child = this.projectionStores.get(parentSessionId)?.values().subagentCatalog
|
|
90
|
+
?.find(entry => entry.id === sessionId);
|
|
91
|
+
if (child !== undefined) {
|
|
92
|
+
return {
|
|
93
|
+
parentSessionId,
|
|
94
|
+
childSessionId: sessionId,
|
|
95
|
+
mode: child.mode,
|
|
96
|
+
};
|
|
144
97
|
}
|
|
145
98
|
}
|
|
146
99
|
return undefined;
|
|
147
100
|
}
|
|
148
101
|
// ---- Instance management ----
|
|
149
102
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* @
|
|
103
|
+
* Withdraw an exact Client instance before running its teardown callbacks.
|
|
104
|
+
* @param sessionId - identity to withdraw.
|
|
105
|
+
* @param expected - instance being released; a replacement is left untouched.
|
|
106
|
+
* @returns completion of the detached instance's stream teardown.
|
|
154
107
|
*/
|
|
155
|
-
|
|
108
|
+
drop(sessionId, expected) {
|
|
156
109
|
const session = this.sessions.get(sessionId);
|
|
110
|
+
if (session !== expected)
|
|
111
|
+
return Promise.resolve();
|
|
157
112
|
this.sessions.delete(sessionId);
|
|
158
|
-
|
|
159
|
-
|
|
113
|
+
this.addresses.delete(sessionId);
|
|
114
|
+
this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
|
|
115
|
+
return this.startSessionDisposal(session);
|
|
160
116
|
}
|
|
161
117
|
/**
|
|
162
|
-
* Stop
|
|
163
|
-
* @returns
|
|
118
|
+
* Stop catalog requests and dispose every resident Session.
|
|
119
|
+
* @returns once catalog requests and every Session stream have stopped.
|
|
164
120
|
*/
|
|
165
121
|
async dispose() {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.
|
|
169
|
-
this.
|
|
170
|
-
this.
|
|
122
|
+
this.disposed = true;
|
|
123
|
+
this.listMutations = null;
|
|
124
|
+
this.listInflight = null;
|
|
125
|
+
this.engagedSessions.clear();
|
|
126
|
+
const reads = [...this.projectionInflight.values()];
|
|
127
|
+
for (const { controller } of reads)
|
|
128
|
+
controller.abort();
|
|
129
|
+
this.projectionInflight.clear();
|
|
130
|
+
await Promise.all(reads.map(read => read.promise));
|
|
171
131
|
const sessions = [...this.sessions.values()];
|
|
172
132
|
this.sessions.clear();
|
|
133
|
+
this.addresses.clear();
|
|
173
134
|
for (const session of sessions)
|
|
174
135
|
void this.startSessionDisposal(session);
|
|
175
136
|
await this.drainSessionDisposals();
|
|
@@ -187,7 +148,8 @@ export class SessionManager {
|
|
|
187
148
|
}
|
|
188
149
|
/**
|
|
189
150
|
* Lazy build: return the existing instance or construct one (no auto-open —
|
|
190
|
-
*
|
|
151
|
+
* the reference allocator opens history after binding the scope).
|
|
152
|
+
* New instances reconcile retained metadata before returning.
|
|
191
153
|
* @param sessionId - the session to get.
|
|
192
154
|
* @returns the resident instance.
|
|
193
155
|
*/
|
|
@@ -196,27 +158,26 @@ export class SessionManager {
|
|
|
196
158
|
if (session === undefined) {
|
|
197
159
|
session = this.createSession(sessionId);
|
|
198
160
|
this.sessions.set(sessionId, session);
|
|
199
|
-
// Install the latest control baseline before the running-bit sync: a
|
|
200
|
-
// not-running summary must sweep replayed queue
|
|
201
|
-
// rows the same way a live status flip would (their retirement events dropped
|
|
202
|
-
// while the session was uninstantiated).
|
|
203
|
-
session.replaceControl(this.queues.get(sessionId) ?? []);
|
|
204
161
|
// Sync the running and blank bits from the list snapshot into the new
|
|
205
162
|
// instance (consistency when the list precedes open).
|
|
206
163
|
const summary = this.summaries.find(s => s.sessionId === sessionId);
|
|
207
164
|
if (summary !== undefined) {
|
|
208
|
-
session.handleBlank(summary
|
|
165
|
+
session.handleBlank(this.effectiveBlank(summary));
|
|
209
166
|
session.handleRunning(summary.running);
|
|
210
167
|
}
|
|
211
168
|
else {
|
|
212
169
|
const address = this.addresses.get(sessionId);
|
|
213
|
-
const child = address === undefined ? undefined : this.
|
|
214
|
-
|
|
215
|
-
if (child
|
|
170
|
+
const child = address === undefined ? undefined : this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog
|
|
171
|
+
?.find(entry => entry.id === sessionId);
|
|
172
|
+
if (child !== undefined) {
|
|
216
173
|
// A catalogued child exists only after its delegated session has
|
|
217
174
|
// durable history, even though child rows do not carry `blank`.
|
|
218
175
|
session.handleBlank(false);
|
|
219
|
-
session.handleRunning(
|
|
176
|
+
session.handleRunning(false);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Retained metadata may have notified before this Session existed.
|
|
180
|
+
session.handleBlank(true);
|
|
220
181
|
}
|
|
221
182
|
}
|
|
222
183
|
}
|
|
@@ -226,131 +187,135 @@ export class SessionManager {
|
|
|
226
187
|
const address = this.addresses.get(sessionId);
|
|
227
188
|
const parentAvailable = address === undefined
|
|
228
189
|
? undefined
|
|
229
|
-
: this.
|
|
190
|
+
: this.agentAvailable(address.parentSessionId);
|
|
230
191
|
return new Session(sessionId, this.remote, {
|
|
231
192
|
...(address === undefined ? {} : {
|
|
232
193
|
address,
|
|
233
|
-
...
|
|
194
|
+
...parentAvailable === undefined ? {} : { parentAvailable },
|
|
234
195
|
}),
|
|
235
196
|
// The sender's local first-send flip mirrors into the list row so the
|
|
236
197
|
// session surfaces (lists filter on blank) before any host frame lands.
|
|
237
198
|
onEngaged: (engaged) => {
|
|
238
|
-
this.
|
|
199
|
+
if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId))
|
|
200
|
+
return;
|
|
201
|
+
// An identity already on the ledger needs no second row mutation.
|
|
202
|
+
if (!this.engagedSessions.has(engaged.sessionId)) {
|
|
203
|
+
this.engagedSessions.add(engaged.sessionId);
|
|
204
|
+
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId });
|
|
205
|
+
}
|
|
206
|
+
this.sessions.get(engaged.sessionId)?.handleBlank(false);
|
|
239
207
|
},
|
|
240
208
|
projections: this.projectionStore(sessionId),
|
|
241
209
|
});
|
|
242
210
|
}
|
|
211
|
+
effectiveBlank(summary) {
|
|
212
|
+
return summary.blank && !this.engagedSessions.has(summary.sessionId);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Identities an engagement may still belong to: the given list rows, resident
|
|
216
|
+
* Session instances, and retained child addresses.
|
|
217
|
+
* @param summaries - list rows of the caller's snapshot.
|
|
218
|
+
* @returns the retained identity set.
|
|
219
|
+
*/
|
|
220
|
+
retainedIds(summaries) {
|
|
221
|
+
const retained = new Set(summaries.map(summary => summary.sessionId));
|
|
222
|
+
for (const sessionId of this.sessions.keys())
|
|
223
|
+
retained.add(sessionId);
|
|
224
|
+
for (const sessionId of this.addresses.keys())
|
|
225
|
+
retained.add(sessionId);
|
|
226
|
+
return retained;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Forget one engagement that no retained identity holds.
|
|
230
|
+
* @param sessionId - identity whose engagement may be dropped.
|
|
231
|
+
* @param retained - identities from {@link retainedIds} for the caller's snapshot.
|
|
232
|
+
*/
|
|
233
|
+
pruneEngagement(sessionId, retained) {
|
|
234
|
+
if (!retained.has(sessionId))
|
|
235
|
+
this.engagedSessions.delete(sessionId);
|
|
236
|
+
}
|
|
243
237
|
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
|
244
238
|
projectionStore(sessionId) {
|
|
245
239
|
let store = this.projectionStores.get(sessionId);
|
|
246
240
|
if (store === undefined) {
|
|
247
241
|
store = new ProjectionValueStore();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
242
|
+
const projections = store;
|
|
243
|
+
store.subscribeAny(() => {
|
|
244
|
+
// Newer history or control metadata corrects a resident Session's stale list hint.
|
|
245
|
+
if (projections.values().sessionListMetadata?.blank === false) {
|
|
246
|
+
this.sessions.get(sessionId)?.handleBlank(false);
|
|
247
|
+
}
|
|
248
|
+
this.notifier.markDirty();
|
|
249
|
+
});
|
|
251
250
|
this.projectionStores.set(sessionId, store);
|
|
252
251
|
}
|
|
253
252
|
return store;
|
|
254
253
|
}
|
|
255
254
|
/**
|
|
256
|
-
*
|
|
257
|
-
* @param
|
|
255
|
+
* Load a complete projection baseline once per connection; retry unsuccessful reads.
|
|
256
|
+
* @param sessionId - Session to inspect without opening its conversation.
|
|
257
|
+
* @returns completion of the current or newly started read.
|
|
258
258
|
*/
|
|
259
|
-
|
|
260
|
-
const existing = this.
|
|
259
|
+
refreshProjections(sessionId) {
|
|
260
|
+
const existing = this.projectionInflight.get(sessionId);
|
|
261
261
|
if (existing !== undefined)
|
|
262
262
|
return existing.promise;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
this.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
? {}
|
|
270
|
-
: { parentAvailable: previous.parentAvailable }),
|
|
271
|
-
state: 'loading',
|
|
272
|
-
error: null,
|
|
273
|
-
});
|
|
263
|
+
if (this.projectionLoads.get(sessionId)?.state === 'ready')
|
|
264
|
+
return Promise.resolve();
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const store = this.projectionStore(sessionId);
|
|
267
|
+
const initialValues = store.values();
|
|
268
|
+
this.projectionLoads.set(sessionId, { state: 'loading', error: null });
|
|
274
269
|
this.notifier.markDirty();
|
|
275
270
|
const operation = (async () => {
|
|
276
271
|
try {
|
|
277
|
-
const result = await this.remote.
|
|
272
|
+
const result = await this.remote.session.projections({ sessionId }, controller.signal);
|
|
273
|
+
if (controller.signal.aborted)
|
|
274
|
+
return;
|
|
278
275
|
if (result.ok) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
state: 'ready',
|
|
286
|
-
error: null,
|
|
287
|
-
});
|
|
288
|
-
for (const [childId, address] of this.addresses) {
|
|
289
|
-
if (address.parentSessionId !== parentSessionId)
|
|
290
|
-
continue;
|
|
291
|
-
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable);
|
|
276
|
+
if (result.value !== null) {
|
|
277
|
+
store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) });
|
|
278
|
+
}
|
|
279
|
+
else if (store.values() === initialValues) {
|
|
280
|
+
// A later frame proves existence independently of an earlier missing read.
|
|
281
|
+
store.clear();
|
|
292
282
|
}
|
|
283
|
+
this.projectionLoads.set(sessionId, { state: 'ready', error: null });
|
|
293
284
|
}
|
|
294
285
|
else {
|
|
295
|
-
this.
|
|
296
|
-
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
297
|
-
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
298
|
-
?? previous?.parentAvailable),
|
|
299
|
-
state: 'error',
|
|
300
|
-
error: result.error,
|
|
301
|
-
});
|
|
286
|
+
this.projectionLoads.set(sessionId, { state: 'error', error: result.error });
|
|
302
287
|
}
|
|
303
288
|
}
|
|
304
289
|
catch (error) {
|
|
290
|
+
if (controller.signal.aborted)
|
|
291
|
+
return;
|
|
305
292
|
if (!isRemoteFailure(error))
|
|
306
293
|
throw error;
|
|
307
|
-
this.
|
|
308
|
-
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
309
|
-
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
310
|
-
?? previous?.parentAvailable),
|
|
311
|
-
state: 'error',
|
|
312
|
-
error,
|
|
313
|
-
});
|
|
294
|
+
this.projectionLoads.set(sessionId, { state: 'error', error });
|
|
314
295
|
}
|
|
315
296
|
finally {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (this.catalogStale.delete(parentSessionId))
|
|
321
|
-
void this.refreshSubagents(parentSessionId);
|
|
322
|
-
this.notifier.markDirty();
|
|
297
|
+
if (!controller.signal.aborted) {
|
|
298
|
+
this.projectionInflight.delete(sessionId);
|
|
299
|
+
this.notifier.markDirty();
|
|
300
|
+
}
|
|
323
301
|
}
|
|
324
302
|
})();
|
|
325
|
-
this.
|
|
326
|
-
promise: operation,
|
|
327
|
-
expandableRows,
|
|
328
|
-
activityRows,
|
|
329
|
-
parentAvailableOverride: undefined,
|
|
330
|
-
});
|
|
303
|
+
this.projectionInflight.set(sessionId, { promise: operation, controller });
|
|
331
304
|
return operation;
|
|
332
305
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
this.openCatalogs.delete(parentSessionId);
|
|
345
|
-
const timer = this.catalogDebounce.get(parentSessionId);
|
|
346
|
-
if (timer !== undefined) {
|
|
347
|
-
clearTimeout(timer);
|
|
348
|
-
this.catalogDebounce.delete(parentSessionId);
|
|
349
|
-
}
|
|
306
|
+
agentAvailable(sessionId) {
|
|
307
|
+
return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable
|
|
308
|
+
?? (this.listPhase === 'ready' ? false : undefined);
|
|
309
|
+
}
|
|
310
|
+
updateParentAvailability() {
|
|
311
|
+
for (const [childId, address] of this.addresses) {
|
|
312
|
+
const available = this.agentAvailable(address.parentSessionId);
|
|
313
|
+
if (available !== undefined)
|
|
314
|
+
this.sessions.get(childId)?.handleSubagentParentAvailable(available);
|
|
350
315
|
}
|
|
351
316
|
}
|
|
352
317
|
// ---- List API ----
|
|
353
|
-
/** Full refresh via session.list (single-flight
|
|
318
|
+
/** Full refresh via session.list (single-flight within one Host generation). */
|
|
354
319
|
refreshList() {
|
|
355
320
|
if (this.listInflight !== null)
|
|
356
321
|
return this.listInflight;
|
|
@@ -363,52 +328,46 @@ export class SessionManager {
|
|
|
363
328
|
this.listInflight = (async () => {
|
|
364
329
|
try {
|
|
365
330
|
const result = await this.remote.session.list({});
|
|
331
|
+
if (this.listMutations !== mutations)
|
|
332
|
+
return;
|
|
366
333
|
if (result.ok) {
|
|
367
334
|
const baseline = this.listPhase === 'pending'
|
|
368
335
|
? [...result.value.items]
|
|
369
336
|
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId);
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
// replayed mutation: an edge that happens entirely between mutations
|
|
373
|
-
// (baseline idle → running → idle) must still arm, which a single
|
|
374
|
-
// sync on the folded result would collapse away.
|
|
375
|
-
for (const s of baseline) {
|
|
376
|
-
if (!this.prevRunning.has(s.sessionId))
|
|
377
|
-
this.prevRunning.set(s.sessionId, s.running);
|
|
378
|
-
}
|
|
379
|
-
let summaries = baseline;
|
|
337
|
+
// A removal supersedes running observed in the pull, including after re-addition.
|
|
338
|
+
const removedSincePull = new Set();
|
|
380
339
|
for (const mutation of mutations) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.syncCompletedNotifications();
|
|
340
|
+
if (mutation.kind === 'remove')
|
|
341
|
+
removedSincePull.add(mutation.sessionId);
|
|
384
342
|
}
|
|
343
|
+
for (const s of baseline) {
|
|
344
|
+
if (s.running && !removedSincePull.has(s.sessionId))
|
|
345
|
+
this.engagedSessions.add(s.sessionId);
|
|
346
|
+
}
|
|
347
|
+
const summaries = mutations.reduce(applyMutation, baseline);
|
|
385
348
|
this.summaries = summaries;
|
|
349
|
+
// A full list can remove identities without a removal frame.
|
|
350
|
+
const retained = this.retainedIds(summaries);
|
|
351
|
+
for (const sessionId of this.engagedSessions)
|
|
352
|
+
this.pruneEngagement(sessionId, retained);
|
|
386
353
|
this.listState = 'idle';
|
|
387
354
|
this.listPhase = 'ready';
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
|
355
|
+
this.updateParentAvailability();
|
|
356
|
+
// Resident Sessions and list rows share one display blank, reconciled with the metadata projection.
|
|
391
357
|
for (const s of this.summaries) {
|
|
392
358
|
const session = this.sessions.get(s.sessionId);
|
|
393
359
|
if (session === undefined)
|
|
394
360
|
continue;
|
|
395
|
-
session.handleBlank(s
|
|
361
|
+
session.handleBlank(this.effectiveBlank(s));
|
|
396
362
|
session.handleRunning(s.running);
|
|
397
363
|
}
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// must not clear; higher-seq-wins still keeps a stale list block
|
|
403
|
-
// from overwriting a newer push frame or tail baseline.
|
|
364
|
+
// Land each row's projection block in the per-session value store
|
|
365
|
+
// (cold titles surface without opening the session). A list block is
|
|
366
|
+
// partial — only the keys its source holds — so an absent key never
|
|
367
|
+
// clears; how the present keys merge depends on the block's kind.
|
|
404
368
|
for (const s of result.value.items) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
continue;
|
|
408
|
-
const store = this.projectionStore(s.sessionId);
|
|
409
|
-
const values = block.values;
|
|
410
|
-
for (const key of Object.keys(values))
|
|
411
|
-
store.apply(key, values[key], sessionSeqCursor(block.asOfSeq));
|
|
369
|
+
if (s.projections !== undefined)
|
|
370
|
+
this.applyListBlock(s.sessionId, s.projections);
|
|
412
371
|
}
|
|
413
372
|
}
|
|
414
373
|
else {
|
|
@@ -419,13 +378,17 @@ export class SessionManager {
|
|
|
419
378
|
catch (error) {
|
|
420
379
|
if (!isRemoteFailure(error))
|
|
421
380
|
throw error;
|
|
381
|
+
if (this.listMutations !== mutations)
|
|
382
|
+
return;
|
|
422
383
|
this.listState = 'error';
|
|
423
384
|
this.listError = error;
|
|
424
385
|
}
|
|
425
386
|
finally {
|
|
426
|
-
this.listMutations
|
|
427
|
-
|
|
428
|
-
|
|
387
|
+
if (this.listMutations === mutations) {
|
|
388
|
+
this.listMutations = null;
|
|
389
|
+
this.listInflight = null;
|
|
390
|
+
this.notifier.markDirty();
|
|
391
|
+
}
|
|
429
392
|
}
|
|
430
393
|
})();
|
|
431
394
|
return this.listInflight;
|
|
@@ -463,7 +426,7 @@ export class SessionManager {
|
|
|
463
426
|
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared };
|
|
464
427
|
const result = await this.remote.session.create(payload);
|
|
465
428
|
if (result.ok) {
|
|
466
|
-
this.recordMutation({ kind: '
|
|
429
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
467
430
|
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
|
468
431
|
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
469
432
|
} });
|
|
@@ -474,7 +437,7 @@ export class SessionManager {
|
|
|
474
437
|
// so expose it immediately as Ungrouped while the caller keeps the
|
|
475
438
|
// prompt buffer and decides whether to retry attachment.
|
|
476
439
|
if (publishedSessionId !== undefined) {
|
|
477
|
-
this.recordMutation({ kind: '
|
|
440
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
478
441
|
sessionId: publishedSessionId,
|
|
479
442
|
updatedAt: Date.now(),
|
|
480
443
|
running: false,
|
|
@@ -486,11 +449,12 @@ export class SessionManager {
|
|
|
486
449
|
}
|
|
487
450
|
/**
|
|
488
451
|
* Contract session.fork; on success merge the child into summaries
|
|
489
|
-
* immediately (same synchronous-addressability guarantee as create).
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
452
|
+
* immediately (same synchronous-addressability guarantee as create).
|
|
453
|
+
* Blankness starts provisionally true so the authoritative Host summary can
|
|
454
|
+
* preserve it or lower it after an exact cut before the first `turn/start`;
|
|
455
|
+
* lineage rides parentSessionId. A child published before Workspace
|
|
456
|
+
* attachment fails is also reconciled into the list.
|
|
457
|
+
* @param opts - source session and the optional exact inclusive boundary seq.
|
|
494
458
|
* @returns the fork result (the child session id).
|
|
495
459
|
*/
|
|
496
460
|
async fork(opts) {
|
|
@@ -503,8 +467,8 @@ export class SessionManager {
|
|
|
503
467
|
? result.value.sessionId
|
|
504
468
|
: workspaceAttachSessionId(result.error);
|
|
505
469
|
if (childId !== undefined) {
|
|
506
|
-
this.recordMutation({ kind: '
|
|
507
|
-
sessionId: childId, updatedAt: Date.now(), running: false, blank:
|
|
470
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
471
|
+
sessionId: childId, updatedAt: Date.now(), running: false, blank: true,
|
|
508
472
|
parentSessionId: opts.sessionId,
|
|
509
473
|
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
|
510
474
|
} });
|
|
@@ -512,20 +476,32 @@ export class SessionManager {
|
|
|
512
476
|
return result;
|
|
513
477
|
}
|
|
514
478
|
/**
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
479
|
+
* Rename a Session and update its title projection without opening its history.
|
|
480
|
+
* @param sessionId - Session to rename.
|
|
481
|
+
* @param title - raw title text for Host normalization.
|
|
482
|
+
* @returns the accepted title and event position, or the Remote failure.
|
|
483
|
+
*/
|
|
484
|
+
async rename(sessionId, title) {
|
|
485
|
+
const result = await this.remote.session.rename({ sessionId, title });
|
|
486
|
+
if (result.ok) {
|
|
487
|
+
this.projectionStore(sessionId).apply('title', result.value.title, SessionSeq(result.value.seq));
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Merge a Host summary, replacing live state and filling missing metadata.
|
|
493
|
+
* Local create/fork placeholders only fill metadata on an existing row.
|
|
519
494
|
*/
|
|
520
495
|
mergeSummary(summary) {
|
|
521
496
|
this.recordMutation({ kind: 'upsert', summary });
|
|
497
|
+
this.updateParentAvailability();
|
|
522
498
|
}
|
|
523
499
|
/** Apply immediately and retain for replay when a list response is in flight. */
|
|
524
500
|
recordMutation(mutation) {
|
|
501
|
+
if (this.disposed)
|
|
502
|
+
return;
|
|
525
503
|
this.listMutations?.push(mutation);
|
|
526
504
|
this.summaries = applyMutation(this.summaries, mutation);
|
|
527
|
-
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
|
|
528
|
-
this.syncCompletedNotifications();
|
|
529
505
|
this.notifier.markDirty();
|
|
530
506
|
}
|
|
531
507
|
// ---- Subscription API (for useSessionList) ----
|
|
@@ -545,6 +521,14 @@ export class SessionManager {
|
|
|
545
521
|
this.notifier.ensureFresh();
|
|
546
522
|
return this.listSnapshotCache;
|
|
547
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* Read cached projection values for a Session that may exist only in a loaded subagent catalog.
|
|
526
|
+
* @param sessionId - Session whose control or history baseline supplied projections.
|
|
527
|
+
* @returns current values, or undefined before any projection store exists.
|
|
528
|
+
*/
|
|
529
|
+
projectionValues(sessionId) {
|
|
530
|
+
return this.projectionStores.get(sessionId)?.values();
|
|
531
|
+
}
|
|
548
532
|
// ---- Live control and Host-event sinks ----
|
|
549
533
|
/**
|
|
550
534
|
* Apply a complete control baseline or one later replacement frame.
|
|
@@ -555,41 +539,15 @@ export class SessionManager {
|
|
|
555
539
|
this.replaceControlBaseline(frame.value);
|
|
556
540
|
return;
|
|
557
541
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
this.notifier.markDirty();
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
if (frame.type === 'jobs') {
|
|
564
|
-
if (frame.jobs.length === 0)
|
|
565
|
-
this.jobsBySession.delete(frame.sessionId);
|
|
566
|
-
else
|
|
567
|
-
this.jobsBySession.set(frame.sessionId, frame.jobs);
|
|
568
|
-
this.notifier.markDirty();
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
this.queues.set(frame.sessionId, frame.items);
|
|
572
|
-
this.sessions.get(frame.sessionId)?.handleControlFrame(frame);
|
|
542
|
+
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
|
|
543
|
+
this.notifier.markDirty();
|
|
573
544
|
}
|
|
574
545
|
replaceControlBaseline(baseline) {
|
|
575
|
-
this.queues.clear();
|
|
576
|
-
for (const [sessionId, items] of Object.entries(baseline.queues)) {
|
|
577
|
-
this.queues.set(sessionId, items);
|
|
578
|
-
}
|
|
579
|
-
this.jobsBySession.clear();
|
|
580
|
-
for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
|
|
581
|
-
if (jobs.length > 0)
|
|
582
|
-
this.jobsBySession.set(sessionId, jobs);
|
|
583
|
-
}
|
|
584
546
|
for (const [sessionId, block] of Object.entries(baseline.projections)) {
|
|
585
547
|
const store = this.projectionStore(sessionId);
|
|
586
548
|
const asOfSeq = sessionSeqCursor(block.asOfSeq);
|
|
587
|
-
store.truncate(asOfSeq);
|
|
588
549
|
store.seed({ ...block, asOfSeq });
|
|
589
550
|
}
|
|
590
|
-
for (const [sessionId, session] of this.sessions) {
|
|
591
|
-
session.replaceControl(this.queues.get(sessionId) ?? []);
|
|
592
|
-
}
|
|
593
551
|
this.notifier.markDirty();
|
|
594
552
|
}
|
|
595
553
|
/**
|
|
@@ -598,20 +556,34 @@ export class SessionManager {
|
|
|
598
556
|
*/
|
|
599
557
|
handleSessionAdded(summary) {
|
|
600
558
|
this.mergeSummary(summary);
|
|
601
|
-
this.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
559
|
+
if (!this.disposed && summary.running)
|
|
560
|
+
this.engagedSessions.add(summary.sessionId);
|
|
561
|
+
this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary));
|
|
562
|
+
if (summary.projections !== undefined)
|
|
563
|
+
this.applyListBlock(summary.sessionId, summary.projections);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Merge one list-surface projection block by the sequence space it declares.
|
|
567
|
+
* A `sequenced` block came from the Host's live registry for an attached
|
|
568
|
+
* Session, so each key lands under higher-seq-wins against that Session's
|
|
569
|
+
* baselines and frames. A `cached` block was viewed from the persisted
|
|
570
|
+
* checkpoint by a header-only listing: its watermark is not comparable with
|
|
571
|
+
* this connection's seqs, so it only fills keys no sequenced row holds.
|
|
572
|
+
*/
|
|
573
|
+
applyListBlock(sessionId, block) {
|
|
574
|
+
const store = this.projectionStore(sessionId);
|
|
575
|
+
switch (block.kind) {
|
|
576
|
+
case 'sequenced': {
|
|
577
|
+
const seq = sessionSeqCursor(block.asOfSeq);
|
|
578
|
+
for (const [key, value] of Object.entries(block.values))
|
|
579
|
+
store.apply(key, value, seq);
|
|
580
|
+
return;
|
|
607
581
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
&& (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
|
|
614
|
-
this.scheduleCatalogRefresh(summary.parentSessionId);
|
|
582
|
+
case 'cached':
|
|
583
|
+
store.applyCached(block.values);
|
|
584
|
+
return;
|
|
585
|
+
default:
|
|
586
|
+
assertNever(block.kind, 'session list projection block kind');
|
|
615
587
|
}
|
|
616
588
|
}
|
|
617
589
|
/**
|
|
@@ -619,29 +591,23 @@ export class SessionManager {
|
|
|
619
591
|
* @param sessionId - removed Session identity.
|
|
620
592
|
*/
|
|
621
593
|
handleSessionRemoved(sessionId) {
|
|
622
|
-
const
|
|
623
|
-
|
|
594
|
+
const durableSubagent = this.subagentAddress(sessionId) !== undefined
|
|
595
|
+
|| this.summaries.some(summary => summary.sessionId === sessionId && summary.origin === 'subagent');
|
|
624
596
|
this.recordMutation(durableSubagent
|
|
625
|
-
? { kind: 'status', sessionId, running: false }
|
|
597
|
+
? { kind: 'status', sessionId, running: false, agentAvailable: false }
|
|
626
598
|
: { kind: 'remove', sessionId });
|
|
627
|
-
this.updateCatalogActivity(sessionId, false);
|
|
628
599
|
if (durableSubagent)
|
|
629
600
|
this.sessions.get(sessionId)?.handleRunning(false);
|
|
630
601
|
else
|
|
631
602
|
this.sessions.get(sessionId)?.handleRemoved();
|
|
632
|
-
this.
|
|
633
|
-
|
|
634
|
-
if (!durableSubagent)
|
|
603
|
+
const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog;
|
|
604
|
+
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
|
|
635
605
|
this.projectionStores.delete(sessionId);
|
|
636
|
-
const inflightCatalog = this.catalogInflight.get(sessionId);
|
|
637
|
-
if (inflightCatalog !== undefined) {
|
|
638
|
-
inflightCatalog.parentAvailableOverride = false;
|
|
639
|
-
this.catalogStale.add(sessionId);
|
|
640
|
-
}
|
|
641
|
-
const ownedCatalog = this.catalogs.get(sessionId);
|
|
642
|
-
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
|
|
643
|
-
this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false });
|
|
644
606
|
}
|
|
607
|
+
this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
|
|
608
|
+
this.projectionInflight.get(sessionId)?.controller.abort();
|
|
609
|
+
this.projectionInflight.delete(sessionId);
|
|
610
|
+
this.projectionLoads.delete(sessionId);
|
|
645
611
|
for (const [childId, address] of this.addresses) {
|
|
646
612
|
if (address.parentSessionId === sessionId) {
|
|
647
613
|
this.sessions.get(childId)?.handleSubagentParentAvailable(false);
|
|
@@ -654,9 +620,11 @@ export class SessionManager {
|
|
|
654
620
|
* @param running - current Agent running state.
|
|
655
621
|
*/
|
|
656
622
|
handleSessionStatus(sessionId, running) {
|
|
657
|
-
this.
|
|
623
|
+
if (!this.disposed && running)
|
|
624
|
+
this.engagedSessions.add(sessionId);
|
|
625
|
+
this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true });
|
|
626
|
+
this.updateParentAvailability();
|
|
658
627
|
this.sessions.get(sessionId)?.handleRunning(running);
|
|
659
|
-
this.updateCatalogActivity(sessionId, running);
|
|
660
628
|
}
|
|
661
629
|
/**
|
|
662
630
|
* Advance Session-list activity from one user-authored durable message.
|
|
@@ -676,128 +644,28 @@ export class SessionManager {
|
|
|
676
644
|
}
|
|
677
645
|
/**
|
|
678
646
|
* Repair one re-established Host-event generation with queryable baselines.
|
|
647
|
+
* Discard old projection cuts before new queries, including cold Sessions
|
|
648
|
+
* absent from the process-local control baseline.
|
|
679
649
|
* Opened Session follow streams resume independently through API Gateway.
|
|
680
650
|
*/
|
|
681
651
|
handleConnected() {
|
|
652
|
+
for (const store of this.projectionStores.values())
|
|
653
|
+
store.clear();
|
|
654
|
+
this.listMutations = null;
|
|
655
|
+
this.listInflight = null;
|
|
682
656
|
void this.refreshList();
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const timer = setTimeout(() => {
|
|
696
|
-
this.catalogDebounce.delete(parentSessionId);
|
|
697
|
-
// The in-flight response predates the membership frame that scheduled
|
|
698
|
-
// this callback. Queue one post-settlement pull instead of treating an
|
|
699
|
-
// ordinary overlapping read as evidence that catalog membership changed.
|
|
700
|
-
if (this.catalogInflight.has(parentSessionId)) {
|
|
701
|
-
this.catalogStale.add(parentSessionId);
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
void this.refreshSubagents(parentSessionId);
|
|
705
|
-
}, 50);
|
|
706
|
-
this.catalogDebounce.set(parentSessionId, timer);
|
|
707
|
-
}
|
|
708
|
-
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
|
|
709
|
-
updateCatalogActivity(childSessionId, running) {
|
|
710
|
-
const activity = running ? 'running' : 'inactive';
|
|
711
|
-
for (const inflight of this.catalogInflight.values()) {
|
|
712
|
-
inflight.activityRows.set(childSessionId, activity);
|
|
713
|
-
}
|
|
714
|
-
let changed = false;
|
|
715
|
-
for (const [parentSessionId, catalog] of this.catalogs) {
|
|
716
|
-
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity))
|
|
717
|
-
continue;
|
|
718
|
-
const entries = catalog.entries.map((entry) => {
|
|
719
|
-
if (entry.kind !== 'child' || entry.id !== childSessionId)
|
|
720
|
-
return entry;
|
|
721
|
-
return { ...entry, activity };
|
|
722
|
-
});
|
|
723
|
-
changed = true;
|
|
724
|
-
this.catalogs.set(parentSessionId, { ...catalog, entries });
|
|
725
|
-
}
|
|
726
|
-
if (changed)
|
|
727
|
-
this.notifier.markDirty();
|
|
728
|
-
}
|
|
729
|
-
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
|
|
730
|
-
markCatalogParentExpandable(parentSessionId) {
|
|
731
|
-
this.applyCatalogParentExpandable(parentSessionId);
|
|
732
|
-
for (const inflight of this.catalogInflight.values())
|
|
733
|
-
inflight.expandableRows.add(parentSessionId);
|
|
734
|
-
}
|
|
735
|
-
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
|
|
736
|
-
applyCatalogParentExpandable(parentSessionId) {
|
|
737
|
-
let changed = false;
|
|
738
|
-
for (const [catalogParentId, catalog] of this.catalogs) {
|
|
739
|
-
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren))
|
|
740
|
-
continue;
|
|
741
|
-
const entries = catalog.entries.map((entry) => {
|
|
742
|
-
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren)
|
|
743
|
-
return entry;
|
|
744
|
-
return { ...entry, hasChildren: true };
|
|
745
|
-
});
|
|
746
|
-
changed = true;
|
|
747
|
-
this.catalogs.set(catalogParentId, { ...catalog, entries });
|
|
748
|
-
}
|
|
749
|
-
if (changed)
|
|
750
|
-
this.notifier.markDirty();
|
|
751
|
-
}
|
|
752
|
-
/** Fold request-local row mutations into one catalog result before publication. */
|
|
753
|
-
withCatalogMutations(entries, expandableRows, activityRows) {
|
|
754
|
-
return entries.map((entry) => {
|
|
755
|
-
if (entry.kind !== 'child')
|
|
756
|
-
return entry;
|
|
757
|
-
const activity = activityRows.get(entry.id);
|
|
758
|
-
if (!expandableRows.has(entry.id) && activity === undefined)
|
|
759
|
-
return entry;
|
|
760
|
-
return {
|
|
761
|
-
...entry,
|
|
762
|
-
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
|
|
763
|
-
...activity === undefined ? {} : { activity },
|
|
764
|
-
};
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Reconcile completion reminders against the latest summaries, eagerly after
|
|
769
|
-
* every mutation and pull (a snapshot-build-time pass would collapse
|
|
770
|
-
* consecutive status frames into one observation). A running→idle edge of a
|
|
771
|
-
* non-selected session arms its reminder; running disarms it; removal drops
|
|
772
|
-
* it. First observation only records the running bit — sessions already
|
|
773
|
-
* idle at load get no reminder.
|
|
774
|
-
*/
|
|
775
|
-
syncCompletedNotifications() {
|
|
776
|
-
const seen = new Set();
|
|
777
|
-
for (const s of this.summaries) {
|
|
778
|
-
seen.add(s.sessionId);
|
|
779
|
-
const prev = this.prevRunning.get(s.sessionId);
|
|
780
|
-
if (prev === undefined) {
|
|
781
|
-
this.prevRunning.set(s.sessionId, s.running);
|
|
782
|
-
continue;
|
|
783
|
-
}
|
|
784
|
-
if (prev && !s.running) {
|
|
785
|
-
if (s.sessionId !== this.selected)
|
|
786
|
-
this.completedNotifications.add(s.sessionId);
|
|
787
|
-
}
|
|
788
|
-
else if (s.running) {
|
|
789
|
-
this.completedNotifications.delete(s.sessionId);
|
|
790
|
-
}
|
|
791
|
-
this.prevRunning.set(s.sessionId, s.running);
|
|
792
|
-
}
|
|
793
|
-
for (const id of this.prevRunning.keys()) {
|
|
794
|
-
if (!seen.has(id))
|
|
795
|
-
this.prevRunning.delete(id);
|
|
796
|
-
}
|
|
797
|
-
for (const id of this.completedNotifications) {
|
|
798
|
-
if (!seen.has(id))
|
|
799
|
-
this.completedNotifications.delete(id);
|
|
800
|
-
}
|
|
657
|
+
const parents = new Set(this.projectionLoads.keys());
|
|
658
|
+
for (const id of this.sessions.keys()) {
|
|
659
|
+
const address = this.addresses.get(id);
|
|
660
|
+
if (address !== undefined)
|
|
661
|
+
parents.add(address.parentSessionId);
|
|
662
|
+
}
|
|
663
|
+
for (const { controller } of this.projectionInflight.values())
|
|
664
|
+
controller.abort();
|
|
665
|
+
this.projectionInflight.clear();
|
|
666
|
+
this.projectionLoads.clear();
|
|
667
|
+
for (const parentSessionId of parents)
|
|
668
|
+
void this.refreshProjections(parentSessionId);
|
|
801
669
|
}
|
|
802
670
|
buildListSnapshot() {
|
|
803
671
|
const merged = this.summaries.map((summary) => {
|
|
@@ -806,53 +674,53 @@ export class SessionManager {
|
|
|
806
674
|
const projectionStore = this.projectionStores.get(summary.sessionId);
|
|
807
675
|
const title = projectionStore?.get('title');
|
|
808
676
|
const projectionValues = projectionStore?.values();
|
|
677
|
+
const metadata = projectionValues?.sessionListMetadata;
|
|
809
678
|
return {
|
|
810
679
|
...summary,
|
|
680
|
+
// Cached list hints can precede a history opening or control update.
|
|
681
|
+
blank: this.effectiveBlank(summary) && metadata?.blank !== false,
|
|
682
|
+
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
|
|
811
683
|
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
|
812
684
|
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
813
685
|
};
|
|
814
686
|
});
|
|
815
|
-
const fresh = flattenLineage(merged
|
|
687
|
+
const fresh = flattenLineage(merged);
|
|
816
688
|
const items = fresh.map((entry) => {
|
|
817
689
|
const prev = this.entryCache.get(entry.sessionId);
|
|
818
690
|
if (prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
|
819
691
|
&& prev.blank === entry.blank
|
|
820
692
|
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
|
821
693
|
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
|
822
|
-
&& prev.projectionValues === entry.projectionValues
|
|
823
|
-
&& prev.completed === entry.completed)
|
|
694
|
+
&& prev.projectionValues === entry.projectionValues)
|
|
824
695
|
return prev;
|
|
825
696
|
this.entryCache.set(entry.sessionId, entry);
|
|
826
697
|
return entry;
|
|
827
698
|
});
|
|
699
|
+
const itemIds = new Set(items.map(entry => entry.sessionId));
|
|
828
700
|
for (const id of this.entryCache.keys()) {
|
|
829
|
-
if (!
|
|
701
|
+
if (!itemIds.has(id))
|
|
830
702
|
this.entryCache.delete(id);
|
|
831
703
|
}
|
|
832
704
|
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]);
|
|
833
705
|
if (!sameOrder)
|
|
834
706
|
this.itemsCache = items;
|
|
835
|
-
const selected = this.selected;
|
|
836
|
-
const current = selected !== undefined
|
|
837
|
-
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
|
|
838
|
-
? selected
|
|
839
|
-
: undefined;
|
|
840
707
|
return {
|
|
841
708
|
items: this.itemsCache,
|
|
842
|
-
current,
|
|
843
709
|
state: this.listState,
|
|
844
710
|
phase: this.listPhase,
|
|
845
711
|
error: this.listError,
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
712
|
+
projectionsBySession: Object.fromEntries([...this.projectionStores].map(([sessionId, store]) => [
|
|
713
|
+
sessionId,
|
|
714
|
+
{ values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) },
|
|
715
|
+
])),
|
|
849
716
|
};
|
|
850
717
|
}
|
|
851
718
|
}
|
|
852
719
|
/** Apply one list mutation without deriving display order. */
|
|
853
720
|
function applyMutation(summaries, mutation) {
|
|
854
721
|
switch (mutation.kind) {
|
|
855
|
-
case 'upsert':
|
|
722
|
+
case 'upsert':
|
|
723
|
+
case 'placeholder': {
|
|
856
724
|
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId);
|
|
857
725
|
if (existing === undefined)
|
|
858
726
|
return [mutation.summary, ...summaries];
|
|
@@ -861,6 +729,10 @@ function applyMutation(summaries, mutation) {
|
|
|
861
729
|
// Blank only lowers: a stale true (session-added racing the local
|
|
862
730
|
// first send) never re-hides an already-surfaced session.
|
|
863
731
|
blank: existing.blank && mutation.summary.blank,
|
|
732
|
+
...(mutation.kind === 'upsert' ? {
|
|
733
|
+
agentAvailable: mutation.summary.agentAvailable,
|
|
734
|
+
running: mutation.summary.running,
|
|
735
|
+
} : {}),
|
|
864
736
|
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
|
865
737
|
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
|
866
738
|
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
|
@@ -868,18 +740,19 @@ function applyMutation(summaries, mutation) {
|
|
|
868
740
|
? { origin: mutation.summary.origin } : {}),
|
|
869
741
|
};
|
|
870
742
|
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
|
871
|
-
&& filled.origin === existing.origin && filled.blank === existing.blank
|
|
743
|
+
&& filled.origin === existing.origin && filled.blank === existing.blank
|
|
744
|
+
&& filled.agentAvailable === existing.agentAvailable && filled.running === existing.running)
|
|
872
745
|
return [...summaries];
|
|
873
746
|
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary);
|
|
874
747
|
}
|
|
875
748
|
case 'remove':
|
|
876
749
|
return summaries.filter(summary => summary.sessionId !== mutation.sessionId);
|
|
877
750
|
case 'status':
|
|
878
|
-
//
|
|
879
|
-
// never runs, so the first running frame proves a message landed).
|
|
751
|
+
// Running converts display state; it does not establish durable history.
|
|
880
752
|
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
|
881
|
-
&& (summary.running !== mutation.running
|
|
882
|
-
|
|
753
|
+
&& (summary.running !== mutation.running
|
|
754
|
+
|| summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank))
|
|
755
|
+
? { ...summary, running: mutation.running, agentAvailable: mutation.agentAvailable, blank: summary.blank && !mutation.running }
|
|
883
756
|
: summary);
|
|
884
757
|
case 'activity':
|
|
885
758
|
return summaries.map(summary => summary.sessionId === mutation.sessionId
|