@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,8 +1,8 @@
|
|
|
1
1
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types';
|
|
2
2
|
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path';
|
|
3
3
|
import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.js";
|
|
4
|
-
import { createSnapshotStore, } from '@deepseek-ai/dsh-client-store';
|
|
5
|
-
import { createScope, scopeOf as scopeTagOf } from "../scope.js";
|
|
4
|
+
import { createSnapshotStore, notifySubscribers, } from '@deepseek-ai/dsh-client-store';
|
|
5
|
+
import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from "../scope.js";
|
|
6
6
|
import { SessionManager } from "./manager.js";
|
|
7
7
|
/** Structured session-create failure. */
|
|
8
8
|
export class SessionCreateError extends Error {
|
|
@@ -69,7 +69,74 @@ function increasedForkTitle(title) {
|
|
|
69
69
|
}
|
|
70
70
|
return `${title} (1)`;
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/** Source labels are dictionary keys, including names also present on Object.prototype. */
|
|
73
|
+
function freezeRetainedBy(counts) {
|
|
74
|
+
Object.setPrototypeOf(counts, null);
|
|
75
|
+
return Object.freeze(counts);
|
|
76
|
+
}
|
|
77
|
+
const EMPTY_RETAIN_INFO = Object.freeze({ referenceCount: 0, retainedBy: freezeRetainedBy({}) });
|
|
78
|
+
/** A cancelled waiter releases only its own reference, not the shared opening. */
|
|
79
|
+
async function waitForOpen(opening, signal) {
|
|
80
|
+
if (signal === undefined)
|
|
81
|
+
return opening;
|
|
82
|
+
const aborted = Promise.withResolvers();
|
|
83
|
+
const onAbort = () => { aborted.reject(signal.reason); };
|
|
84
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
try {
|
|
86
|
+
if (signal.aborted)
|
|
87
|
+
onAbort();
|
|
88
|
+
await Promise.race([opening, aborted.promise]);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
signal.removeEventListener('abort', onAbort);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class ClientSessionReference {
|
|
95
|
+
sessionId;
|
|
96
|
+
record;
|
|
97
|
+
releaseReference;
|
|
98
|
+
released = new AbortController();
|
|
99
|
+
readiness = Promise.withResolvers();
|
|
100
|
+
ready = this.readiness.promise;
|
|
101
|
+
constructor(sessionId, record, releaseReference) {
|
|
102
|
+
this.sessionId = sessionId;
|
|
103
|
+
this.record = record;
|
|
104
|
+
this.releaseReference = releaseReference;
|
|
105
|
+
void this.ready.catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
get binding() {
|
|
108
|
+
if (this.record === undefined || !this.record.live)
|
|
109
|
+
throw new Error(`Session reference "${this.sessionId}" is released`);
|
|
110
|
+
return this.record.binding;
|
|
111
|
+
}
|
|
112
|
+
attachOpening(opening, signal) {
|
|
113
|
+
const waitSignal = signal === undefined
|
|
114
|
+
? this.released.signal
|
|
115
|
+
: AbortSignal.any([this.released.signal, signal]);
|
|
116
|
+
void waitForOpen(opening, waitSignal).then(() => {
|
|
117
|
+
try {
|
|
118
|
+
waitSignal.throwIfAborted();
|
|
119
|
+
this.readiness.resolve(this.binding);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.readiness.reject(error);
|
|
123
|
+
}
|
|
124
|
+
}, (error) => { this.readiness.reject(error); });
|
|
125
|
+
}
|
|
126
|
+
release() {
|
|
127
|
+
const reason = new Error(`Session reference "${this.sessionId}" is released`);
|
|
128
|
+
const release = this.releaseReference;
|
|
129
|
+
this.released.abort(reason);
|
|
130
|
+
this.readiness.reject(reason);
|
|
131
|
+
this.record = undefined;
|
|
132
|
+
this.releaseReference = undefined;
|
|
133
|
+
release?.();
|
|
134
|
+
}
|
|
135
|
+
[Symbol.dispose]() {
|
|
136
|
+
this.release();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Host catalog and local reference allocator; view selection remains outside the Controller. */
|
|
73
140
|
export class ClientSessions {
|
|
74
141
|
rootCtx;
|
|
75
142
|
/**
|
|
@@ -79,119 +146,105 @@ export class ClientSessions {
|
|
|
79
146
|
* reports the same number.
|
|
80
147
|
*/
|
|
81
148
|
searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
|
|
82
|
-
/**
|
|
149
|
+
/** Catalog metadata and local reference-source projection. */
|
|
83
150
|
list;
|
|
84
151
|
/** The object-layer instance cluster and frame dispatch entry. */
|
|
85
152
|
manager;
|
|
86
|
-
/**
|
|
87
|
-
* Persisted selection cell (the durable half of `list.current`). Private on
|
|
88
|
-
* purpose: reads go through the list snapshot; writes through {@link
|
|
89
|
-
* ClientSessions.open} / {@link ClientSessions.clear}. Projection
|
|
90
|
-
* validates it against the live list instead of destructively pruning, so a
|
|
91
|
-
* selection survives transient list states (reconnect re-pull) and
|
|
92
|
-
* resurfaces when its session returns.
|
|
93
|
-
*/
|
|
94
|
-
selection;
|
|
95
153
|
scopes = new Map();
|
|
96
|
-
/**
|
|
154
|
+
/** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */
|
|
155
|
+
retainObservers = new Map();
|
|
97
156
|
scopeDrops = new Set();
|
|
98
|
-
|
|
99
|
-
* The staged session id — follows `list.current` exactly, holding its last
|
|
100
|
-
* defined value across masked gaps (a transiently absent selection blanks
|
|
101
|
-
* `current` without moving the stage, so reconnect re-pulls and removals
|
|
102
|
-
* keep the staged scope's frozen view alive until the stage moves on).
|
|
103
|
-
*/
|
|
104
|
-
watched;
|
|
105
|
-
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
|
106
|
-
deferredRemovals = new Set();
|
|
157
|
+
closed = false;
|
|
107
158
|
/**
|
|
108
159
|
* @param ctx - client root context (scope fibers mount under it).
|
|
109
160
|
* @param remote - generated Remote namespaces shared with every Session.
|
|
110
161
|
*/
|
|
111
162
|
constructor(rootCtx, remote) {
|
|
112
163
|
this.rootCtx = rootCtx;
|
|
113
|
-
this.
|
|
114
|
-
const restored = this.selection.getSnapshot();
|
|
115
|
-
this.manager = new SessionManager(remote, restored.sessionId, restored.subagentAddress);
|
|
164
|
+
this.manager = new SessionManager(remote);
|
|
116
165
|
this.list = createSnapshotStore({
|
|
117
|
-
ids: [], byId: {},
|
|
118
|
-
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
|
|
119
|
-
});
|
|
120
|
-
// The manager owns wire truth; the store is its projection. Manager
|
|
121
|
-
// notifications are already microtask-batched.
|
|
122
|
-
const disposeManagerProjection = this.manager.subscribe(() => {
|
|
123
|
-
this.projectList();
|
|
124
|
-
});
|
|
125
|
-
// Stage follower: every current write (open() and projection alike)
|
|
126
|
-
// re-evaluates staging, so startup restore (persisted selection validated
|
|
127
|
-
// by the projection) and reconnect resurfacing open their window with no
|
|
128
|
-
// dedicated code path. Safe to run synchronously inside the store notify:
|
|
129
|
-
// the follower writes no list state — session.open()'s synchronous prefix
|
|
130
|
-
// touches only session-side state and its own microtask-batched notifier.
|
|
131
|
-
const disposeStageFollower = this.list.subscribe(() => {
|
|
132
|
-
this.followCurrent();
|
|
166
|
+
ids: [], byId: {}, phase: 'pending', projectionsBySession: {},
|
|
133
167
|
});
|
|
168
|
+
const disposeManagerProjection = this.manager.subscribe(() => { this.projectList(); });
|
|
134
169
|
rootCtx.effect(() => async () => {
|
|
135
|
-
|
|
170
|
+
this.closed = true;
|
|
136
171
|
disposeManagerProjection();
|
|
137
172
|
const scopes = [...this.scopes];
|
|
138
173
|
this.scopes.clear();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
for (const [, record] of scopes) {
|
|
175
|
+
record.live = false;
|
|
176
|
+
record.session.unbindScope();
|
|
177
|
+
}
|
|
178
|
+
const managerDisposal = this.manager.dispose();
|
|
179
|
+
for (const [id, record] of scopes) {
|
|
142
180
|
this.startScopeDrop(id, record);
|
|
181
|
+
this.publishRetention(id);
|
|
182
|
+
}
|
|
143
183
|
await this.drainScopeDrops();
|
|
144
|
-
await
|
|
184
|
+
await managerDisposal;
|
|
145
185
|
}, 'session-controller.client.sessions');
|
|
146
186
|
rootCtx.reflect.provide('sessions', this, undefined);
|
|
147
187
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.manager.
|
|
188
|
+
retain(target, options) {
|
|
189
|
+
const { source, signal } = options;
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
if (this.closed)
|
|
192
|
+
throw new Error('Session Controller is disposed');
|
|
193
|
+
const id = this.manager.resolveTarget(target);
|
|
194
|
+
const reference = this.retainScope(id, source);
|
|
195
|
+
try {
|
|
196
|
+
reference.attachOpening(this.manager.get(id).open(), signal);
|
|
197
|
+
return reference;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
reference.release();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
154
203
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
async using(target, options, operation) {
|
|
205
|
+
const reference = this.retain(target, options);
|
|
206
|
+
try {
|
|
207
|
+
await reference.ready;
|
|
208
|
+
return await operation(reference);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
reference.release();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
retainInfo(id) {
|
|
215
|
+
let observer = this.retainObservers.get(id);
|
|
216
|
+
if (observer === undefined) {
|
|
217
|
+
const listeners = new Set();
|
|
218
|
+
observer = {
|
|
219
|
+
listeners,
|
|
220
|
+
published: this.retentionSnapshot(id),
|
|
221
|
+
source: {
|
|
222
|
+
getSnapshot: () => this.retentionSnapshot(id),
|
|
223
|
+
subscribe: (listener) => {
|
|
224
|
+
listeners.add(listener);
|
|
225
|
+
return () => { listeners.delete(listener); };
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
this.retainObservers.set(id, observer);
|
|
230
|
+
}
|
|
231
|
+
return observer.source;
|
|
161
232
|
}
|
|
162
233
|
/**
|
|
163
234
|
* Resolve an already discovered direct-parent address without opening it.
|
|
164
235
|
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
|
|
165
236
|
* @param id - possible addressed child id.
|
|
166
|
-
* @returns
|
|
237
|
+
* @returns A retained or loaded-catalog address, without retaining a new selection or scope.
|
|
167
238
|
*/
|
|
168
239
|
subagentAddress(id) {
|
|
169
240
|
return this.manager.subagentAddress(id);
|
|
170
241
|
}
|
|
171
242
|
/**
|
|
172
|
-
*
|
|
173
|
-
* @param
|
|
174
|
-
* @param open - menu state.
|
|
243
|
+
* Load all Session projections once per connection; retry an unsuccessful initial read.
|
|
244
|
+
* @param sessionId - Session to inspect without opening its conversation.
|
|
175
245
|
*/
|
|
176
|
-
|
|
177
|
-
this.manager.
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Refresh one direct-child catalog.
|
|
181
|
-
* @param parentSessionId - catalog owner.
|
|
182
|
-
*/
|
|
183
|
-
refreshSubagents(parentSessionId) {
|
|
184
|
-
return this.manager.refreshSubagents(parentSessionId);
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Clear the current selection so the layout shows the no-session empty
|
|
188
|
-
* state (new-session affordance and the workspace preselection flow).
|
|
189
|
-
* Wipes the persisted selection too — a reload stays on empty until the
|
|
190
|
-
* user opens or starts a session. The staged scope keeps its frozen view
|
|
191
|
-
* per the masked-gap contract until the next open() moves the stage.
|
|
192
|
-
*/
|
|
193
|
-
clear() {
|
|
194
|
-
this.manager.clearSelection();
|
|
246
|
+
refreshProjections(sessionId) {
|
|
247
|
+
return this.manager.refreshProjections(sessionId);
|
|
195
248
|
}
|
|
196
249
|
/**
|
|
197
250
|
* Refresh the real Session baseline, reusing an in-flight pull.
|
|
@@ -257,12 +310,8 @@ export class ClientSessions {
|
|
|
257
310
|
this.manager.handleConnected();
|
|
258
311
|
}
|
|
259
312
|
/**
|
|
260
|
-
* Create a
|
|
261
|
-
*
|
|
262
|
-
* {@link ClientSessions.binding} resolves it — callers (New Session
|
|
263
|
-
* draft hand-off) may address the scope synchronously, without waiting a
|
|
264
|
-
* notifier flush. The synchronous projection below makes this structural
|
|
265
|
-
* rather than an accident of microtask ordering.
|
|
313
|
+
* Create a Host Session and publish its catalog row before resolving.
|
|
314
|
+
* Callers retain the returned identity before borrowing its binding.
|
|
266
315
|
* @param opts - target workspace or directory and an optional preallocated id.
|
|
267
316
|
* @returns the new session id.
|
|
268
317
|
* @throws {SessionCreateError} with the requested id.
|
|
@@ -275,16 +324,14 @@ export class ClientSessions {
|
|
|
275
324
|
return result.value.sessionId;
|
|
276
325
|
}
|
|
277
326
|
/**
|
|
278
|
-
* Fork a session from
|
|
327
|
+
* Fork a session from an exact inclusive prefix of the source (same
|
|
279
328
|
* synchronous-addressability guarantee as {@link ClientSessions.create}:
|
|
280
|
-
* on resolution the child is
|
|
281
|
-
* @param opts - source session id, the optional
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* and whether to increment an
|
|
285
|
-
*
|
|
286
|
-
* interrupted turn carry flow-ordering seqs between two events, and the
|
|
287
|
-
* wire takes integers only.
|
|
329
|
+
* on resolution the child is catalogued and may be explicitly retained).
|
|
330
|
+
* @param opts - source session id, the optional exact inclusive boundary
|
|
331
|
+
* seq (a real event seq the caller already knows; a cut inside an open
|
|
332
|
+
* turn is balanced Host-side with synthetic closers, and omission selects
|
|
333
|
+
* the latest completed-turn prefix), and whether to increment an
|
|
334
|
+
* inherited durable title before resolving.
|
|
288
335
|
* @returns the child session id.
|
|
289
336
|
* @throws {SessionForkError} with the source id.
|
|
290
337
|
* @throws {Error} when a requested child-title rename fails after creation.
|
|
@@ -295,43 +342,36 @@ export class ClientSessions {
|
|
|
295
342
|
: undefined;
|
|
296
343
|
const result = await this.manager.fork({
|
|
297
344
|
sessionId: opts.sessionId,
|
|
298
|
-
|
|
299
|
-
// turn/start), so the host's first-turn/end-at-or-after cut still ends
|
|
300
|
-
// on that turn — never clipped back to the previous one.
|
|
301
|
-
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(Math.floor(opts.atSeq)) }),
|
|
345
|
+
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }),
|
|
302
346
|
});
|
|
303
347
|
if (!result.ok)
|
|
304
348
|
throw new SessionForkError(result.error, opts.sessionId);
|
|
305
349
|
this.projectList();
|
|
306
350
|
const childId = result.value.sessionId;
|
|
307
351
|
if (sourceTitle !== undefined) {
|
|
308
|
-
const
|
|
309
|
-
if (child === undefined)
|
|
310
|
-
throw new Error(`fork child "${childId}" is not locally addressable`);
|
|
311
|
-
const renamed = await child.rename(increasedForkTitle(sourceTitle));
|
|
352
|
+
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle));
|
|
312
353
|
if (!renamed.ok)
|
|
313
354
|
throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
|
|
314
355
|
}
|
|
315
356
|
return childId;
|
|
316
357
|
}
|
|
317
358
|
/**
|
|
318
|
-
*
|
|
359
|
+
* Borrow an already-retained Agent-scoped Context.
|
|
319
360
|
* @param id - session id (the agent identity — 1:1 same axis).
|
|
320
|
-
* @returns scoped
|
|
361
|
+
* @returns the scoped Context, or undefined without a retained generation.
|
|
321
362
|
*/
|
|
322
363
|
scope(id) {
|
|
323
|
-
return this.
|
|
364
|
+
return this.scopes.get(id)?.ctx;
|
|
324
365
|
}
|
|
325
366
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* arrival order.
|
|
330
|
-
* @param id - Host-projected Agent identity (the matching Session id).
|
|
331
|
-
* @returns the identity-stable Agent Context.
|
|
367
|
+
* Retain a validated Gateway identity synchronously, without history or catalog I/O.
|
|
368
|
+
* @param id - Host-projected Session identity, possibly not yet catalogued.
|
|
369
|
+
* @returns a Gateway-source reference owned by the invocation.
|
|
332
370
|
*/
|
|
333
|
-
|
|
334
|
-
|
|
371
|
+
retainAgentScope(id) {
|
|
372
|
+
if (this.closed)
|
|
373
|
+
throw new Error('Session Controller is disposed');
|
|
374
|
+
return this.retainScope(id, 'gateway');
|
|
335
375
|
}
|
|
336
376
|
/**
|
|
337
377
|
* Read the Agent scope tag off a context. Service-method boundary: fetch
|
|
@@ -351,62 +391,80 @@ export class ClientSessions {
|
|
|
351
391
|
* `agent.session`). Same service-method boundary as
|
|
352
392
|
* {@link ClientSessions.scopeOf}.
|
|
353
393
|
* @param ctx - an Agent-scoped context.
|
|
354
|
-
* @returns the
|
|
394
|
+
* @returns the matching live Session, or undefined for an untagged or ended generation.
|
|
355
395
|
*/
|
|
356
396
|
sessionOf(ctx) {
|
|
357
397
|
const id = scopeTagOf(ctx);
|
|
358
398
|
if (id === undefined)
|
|
359
399
|
return undefined;
|
|
360
|
-
|
|
400
|
+
const record = this.scopes.get(id);
|
|
401
|
+
return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx)
|
|
402
|
+
? record.binding.session
|
|
403
|
+
: undefined;
|
|
361
404
|
}
|
|
362
405
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* @
|
|
366
|
-
* @returns binding, or undefined for a session neither listed nor already scoped.
|
|
406
|
+
* Borrow an already-retained binding without extending its lifetime.
|
|
407
|
+
* @param id - Session identity.
|
|
408
|
+
* @returns the live binding, or undefined without a retained generation.
|
|
367
409
|
*/
|
|
368
410
|
binding(id) {
|
|
369
|
-
return this.
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
411
|
+
return this.scopes.get(id)?.binding;
|
|
412
|
+
}
|
|
413
|
+
retainScope(id, source) {
|
|
414
|
+
const record = this.scopes.get(id) ?? this.materializeScope(id);
|
|
415
|
+
const previous = record.retention;
|
|
416
|
+
record.retention = Object.freeze({
|
|
417
|
+
referenceCount: previous.referenceCount + 1,
|
|
418
|
+
retainedBy: freezeRetainedBy({ ...previous.retainedBy, [source]: (previous.retainedBy[source] ?? 0) + 1 }),
|
|
419
|
+
});
|
|
420
|
+
const reference = new ClientSessionReference(id, record, () => {
|
|
421
|
+
if (!record.live)
|
|
422
|
+
return;
|
|
423
|
+
const count = record.retention.referenceCount - 1;
|
|
424
|
+
const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy;
|
|
425
|
+
const retainedBy = sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources;
|
|
426
|
+
record.retention = count === 0
|
|
427
|
+
? EMPTY_RETAIN_INFO
|
|
428
|
+
: Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) });
|
|
429
|
+
if (count === 0)
|
|
430
|
+
this.retireScope(id, record);
|
|
431
|
+
else
|
|
432
|
+
this.publishRetention(id);
|
|
433
|
+
});
|
|
434
|
+
if (this.list.getSnapshot().byId[id] === undefined && this.manager.subagentAddress(id) !== undefined) {
|
|
435
|
+
this.projectList();
|
|
436
|
+
}
|
|
437
|
+
this.publishRetention(id);
|
|
438
|
+
return reference;
|
|
439
|
+
}
|
|
440
|
+
retentionSnapshot(id) {
|
|
441
|
+
return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO;
|
|
442
|
+
}
|
|
443
|
+
publishRetention(id) {
|
|
444
|
+
const state = this.list.getSnapshot();
|
|
445
|
+
const row = state.byId[id];
|
|
446
|
+
const retainedBy = this.retentionSnapshot(id).retainedBy;
|
|
447
|
+
if (row !== undefined && row.retainedBy !== retainedBy) {
|
|
448
|
+
this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } });
|
|
395
449
|
}
|
|
450
|
+
const observer = this.retainObservers.get(id);
|
|
451
|
+
const snapshot = this.retentionSnapshot(id);
|
|
452
|
+
if (observer === undefined || observer.published === snapshot)
|
|
453
|
+
return;
|
|
454
|
+
observer.published = snapshot;
|
|
455
|
+
notifySubscribers(observer.listeners, '[session-controller] reference sources');
|
|
396
456
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return undefined;
|
|
409
|
-
return this.materializeScope(id);
|
|
457
|
+
retireScope(id, record, disposeFiber = true) {
|
|
458
|
+
if (!record.live)
|
|
459
|
+
return;
|
|
460
|
+
record.live = false;
|
|
461
|
+
if (this.scopes.get(id) === record)
|
|
462
|
+
this.scopes.delete(id);
|
|
463
|
+
record.session.unbindScope();
|
|
464
|
+
const sessionDisposal = this.manager.drop(id, record.session);
|
|
465
|
+
this.projectList();
|
|
466
|
+
this.publishRetention(id);
|
|
467
|
+
this.startScopeDrop(id, record, disposeFiber, sessionDisposal);
|
|
410
468
|
}
|
|
411
469
|
/** Materialize one scope after its caller establishes that the id may be addressed. */
|
|
412
470
|
materializeScope(id) {
|
|
@@ -421,18 +479,17 @@ export class ClientSessions {
|
|
|
421
479
|
ctx,
|
|
422
480
|
binding,
|
|
423
481
|
session,
|
|
482
|
+
retention: EMPTY_RETAIN_INFO,
|
|
483
|
+
live: true,
|
|
424
484
|
};
|
|
425
485
|
this.scopes.set(id, record);
|
|
486
|
+
ctx.effect(() => () => { this.retireScope(id, record, false); }, 'session-controller: exact generation');
|
|
426
487
|
return record;
|
|
427
488
|
}
|
|
428
|
-
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
|
|
429
|
-
eligible(id) {
|
|
430
|
-
const { ids, current } = this.list.getSnapshot();
|
|
431
|
-
return current === id || ids.includes(id);
|
|
432
|
-
}
|
|
433
489
|
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
|
434
490
|
projectList() {
|
|
435
|
-
const
|
|
491
|
+
const previousById = this.list.getSnapshot().byId;
|
|
492
|
+
const { items, phase, projectionsBySession, } = this.manager.getListSnapshot();
|
|
436
493
|
const ids = [];
|
|
437
494
|
const byId = {};
|
|
438
495
|
for (const entry of items) {
|
|
@@ -441,7 +498,7 @@ export class ClientSessions {
|
|
|
441
498
|
id: entry.sessionId,
|
|
442
499
|
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
|
443
500
|
running: entry.running,
|
|
444
|
-
|
|
501
|
+
retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
|
|
445
502
|
blank: entry.blank,
|
|
446
503
|
updatedAt: entry.updatedAt,
|
|
447
504
|
...(entry.projectionValues === undefined
|
|
@@ -453,76 +510,58 @@ export class ClientSessions {
|
|
|
453
510
|
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
|
454
511
|
};
|
|
455
512
|
}
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
while (address !== undefined && !seen.has(address.childSessionId)) {
|
|
460
|
-
const childId = address.childSessionId;
|
|
461
|
-
seen.add(childId);
|
|
462
|
-
const child = subagentsByParent[address.parentSessionId]?.entries
|
|
463
|
-
.find(entry => entry.kind === 'child' && entry.id === childId);
|
|
464
|
-
if (child?.kind !== 'child')
|
|
465
|
-
break;
|
|
466
|
-
const displayTitle = child.label ?? childId;
|
|
513
|
+
for (const [parentId, projection] of Object.entries(projectionsBySession)) {
|
|
514
|
+
for (const child of projection.values.subagentCatalog ?? []) {
|
|
515
|
+
const childId = child.id;
|
|
467
516
|
const summary = byId[childId];
|
|
517
|
+
const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId);
|
|
518
|
+
const projectedTitle = projectionValues?.title;
|
|
519
|
+
const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined;
|
|
520
|
+
const displayTitle = title ?? child.label ?? childId;
|
|
468
521
|
if (summary === undefined) {
|
|
469
522
|
byId[childId] = {
|
|
470
|
-
id: childId,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
blank: false,
|
|
476
|
-
updatedAt: 0,
|
|
523
|
+
id: childId, displayTitle, parentId: parentId,
|
|
524
|
+
origin: 'subagent', running: this.scopes.get(childId)?.session.getSnapshot().running ?? false, blank: false, updatedAt: 0,
|
|
525
|
+
retainedBy: this.retentionSnapshot(childId).retainedBy,
|
|
526
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
527
|
+
...(title === undefined ? {} : { title }),
|
|
477
528
|
};
|
|
478
529
|
}
|
|
479
|
-
else if (summary.displayTitle !== displayTitle) {
|
|
480
|
-
byId[childId] = {
|
|
530
|
+
else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
|
|
531
|
+
byId[childId] = {
|
|
532
|
+
...summary,
|
|
533
|
+
displayTitle,
|
|
534
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
535
|
+
};
|
|
481
536
|
}
|
|
482
|
-
const parent = byId[address.parentSessionId];
|
|
483
|
-
if (parent !== undefined && parent.origin !== 'subagent')
|
|
484
|
-
break;
|
|
485
|
-
address = this.manager.navigationAddress(address.parentSessionId);
|
|
486
537
|
}
|
|
487
538
|
}
|
|
488
|
-
const persisted = this.selection.getSnapshot().sessionId;
|
|
489
|
-
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
|
490
|
-
// stays on empty; the in-memory selection still resurfaces a masked id.
|
|
491
|
-
if (current === undefined) {
|
|
492
|
-
if (persisted !== undefined)
|
|
493
|
-
this.selection.set({});
|
|
494
|
-
}
|
|
495
|
-
else if (byId[current] !== undefined
|
|
496
|
-
&& (persisted !== current
|
|
497
|
-
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
|
498
|
-
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
|
499
|
-
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
|
500
|
-
this.selection.set({
|
|
501
|
-
sessionId: current,
|
|
502
|
-
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress });
|
|
506
|
-
this.pruneScopes();
|
|
507
|
-
}
|
|
508
|
-
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
|
509
|
-
pruneScopes() {
|
|
510
|
-
if (this.list.getSnapshot().phase === 'pending')
|
|
511
|
-
return;
|
|
512
539
|
for (const [id, record] of this.scopes) {
|
|
513
|
-
if (
|
|
540
|
+
if (byId[id] !== undefined)
|
|
514
541
|
continue;
|
|
515
|
-
|
|
516
|
-
|
|
542
|
+
const address = this.manager.subagentAddress(id);
|
|
543
|
+
if (address === undefined)
|
|
517
544
|
continue;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
this.
|
|
521
|
-
|
|
545
|
+
const previous = previousById[id];
|
|
546
|
+
const snapshot = record.session.getSnapshot();
|
|
547
|
+
const projectionValues = this.manager.projectionValues(id);
|
|
548
|
+
const projectedTitle = projectionValues?.title;
|
|
549
|
+
const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : previous?.title;
|
|
550
|
+
byId[id] = {
|
|
551
|
+
...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
|
|
552
|
+
running: snapshot.running,
|
|
553
|
+
retainedBy: record.retention.retainedBy,
|
|
554
|
+
blank: snapshot.blank,
|
|
555
|
+
parentId: address.parentSessionId,
|
|
556
|
+
origin: 'subagent',
|
|
557
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
558
|
+
...(title === undefined ? {} : { title, displayTitle: title }),
|
|
559
|
+
};
|
|
522
560
|
}
|
|
561
|
+
this.list.set({ ids, byId, phase, projectionsBySession });
|
|
523
562
|
}
|
|
524
|
-
startScopeDrop(id, record) {
|
|
525
|
-
const drop = this.dropScope(
|
|
563
|
+
startScopeDrop(id, record, disposeFiber = true, sessionDisposal = this.manager.drop(id, record.session)) {
|
|
564
|
+
const drop = this.dropScope(record, disposeFiber, sessionDisposal);
|
|
526
565
|
this.scopeDrops.add(drop);
|
|
527
566
|
void drop.then(() => { this.scopeDrops.delete(drop); }, () => { this.scopeDrops.delete(drop); });
|
|
528
567
|
}
|
|
@@ -531,45 +570,9 @@ export class ClientSessions {
|
|
|
531
570
|
await Promise.allSettled([...this.scopeDrops]);
|
|
532
571
|
}
|
|
533
572
|
}
|
|
534
|
-
/**
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
* controller, popup, plugin stores, listeners), the session-keyed slot
|
|
538
|
-
* registrations and the Session instance itself — the host session log is the
|
|
539
|
-
* durable truth, a reopen lazily rebuilds and backfills via open().
|
|
540
|
-
*/
|
|
541
|
-
async dropScope(id, record) {
|
|
542
|
-
// Release the Session's dispatch point with the scope it belongs to (a
|
|
543
|
-
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
|
544
|
-
record.session.unbindScope();
|
|
545
|
-
await Promise.allSettled([
|
|
546
|
-
record.fiber.dispose(),
|
|
547
|
-
this.manager.drop(id),
|
|
548
|
-
]);
|
|
549
|
-
}
|
|
550
|
-
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
|
551
|
-
sweepDeferred() {
|
|
552
|
-
for (const id of [...this.deferredRemovals]) {
|
|
553
|
-
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
|
554
|
-
* stage move sweeps first, so the set cannot contain the id the stage just
|
|
555
|
-
* moved to; kept as a guard against future extra sweep call sites. */
|
|
556
|
-
if (id === this.watched)
|
|
557
|
-
continue;
|
|
558
|
-
// Eligible again? (A re-added id cancels the deferred teardown.)
|
|
559
|
-
if (this.eligible(id)) {
|
|
560
|
-
this.deferredRemovals.delete(id);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
const record = this.scopes.get(id);
|
|
564
|
-
this.deferredRemovals.delete(id);
|
|
565
|
-
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
|
566
|
-
* together, so a deferred id always still owns its record; kept so a
|
|
567
|
-
* future teardown path cannot double-dispose. */
|
|
568
|
-
if (record !== undefined) {
|
|
569
|
-
this.scopes.delete(id);
|
|
570
|
-
this.startScopeDrop(id, record);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
+
/** Await the already-withdrawn Session and scoped cleanup to quiescence. */
|
|
574
|
+
async dropScope(record, disposeFiber, sessionDisposal) {
|
|
575
|
+
await Promise.allSettled([sessionDisposal, ...disposeFiber ? [record.fiber.dispose()] : []]);
|
|
573
576
|
}
|
|
574
577
|
}
|
|
575
578
|
//# sourceMappingURL=service.js.map
|