@4fu/dsh-arbor-driver 0.0.0-stage → 2026.1110.0

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/driver.js ADDED
@@ -0,0 +1,885 @@
1
+ /**
2
+ * The arbor driver: the AgentFactory that runs arbor-preset sessions, and the
3
+ * Cordis plugin row that mounts it.
4
+ *
5
+ * One DSH user session is one conversation target of the process's single Arbor
6
+ * host (`@4fu/dsh-arbor-host`): the journal, the note store and the board are
7
+ * shared by every session, and each session is one source of that one host.
8
+ * Arbor's own model is what a session's windows follow — a window ends when its
9
+ * run ends and the session's next turn opens the next window of its chain, so
10
+ * several windows serve one session and none of them is the session. Per
11
+ * session the driver owns
12
+ *
13
+ * - the window, opened through `host.dispatch` on the session's first turn and
14
+ * taken back — not reopened — when the session already has a live one, which
15
+ * is what a restart's continuation is;
16
+ * - the bridge the session agent drives: `ensureWindow`, `modelCall`,
17
+ * `runToolCall` (arbor's tool path into `ctx.tools.execute`), `publish`,
18
+ * `finishRun`, `close`; and
19
+ * - the window's DSH agent registration, which keeps it out of the session
20
+ * list (`meta.origin: 'subagent'`, owned by the session's agent).
21
+ *
22
+ * `resume` loads the persisted session, replays onto its log what the arbor
23
+ * journal recorded and the log lost, and binds the session back to the window
24
+ * the journal says is its own — the window the host continued at boot.
25
+ *
26
+ * The creation lifecycle mirrors `AgentLoop.createAgent`
27
+ * (packages/core/agent-loop/src/index.ts): prepare the session, take
28
+ * persistence ownership when a backend is mounted, run setup while the agent
29
+ * is unpublished, then enter and announce the session and the agent through
30
+ * the same registries the official loop uses.
31
+ *
32
+ * B1 scope, per the plan: compaction, goals, agentTeams, retry and fork are
33
+ * off for arbor sessions.
34
+ */
35
+ import { randomUUID } from 'node:crypto';
36
+ import { getTraceable, Service, symbols } from '@deepseek-ai/cordis';
37
+ import z from '@deepseek-ai/schemastery';
38
+ import { modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent';
39
+ import { brandString } from '@deepseek-ai/dsh-brand';
40
+ import { SessionPreparation, interruptedTurnClosers } from '@deepseek-ai/dsh-session';
41
+ import { createToolResultMessage } from '@deepseek-ai/dsh-llm';
42
+ import { sessionTarget } from '@4fu/dsh-arbor-host';
43
+ import { replacedToolsRegistered } from '@4fu/dsh-arbor-tools';
44
+ import { dataBlock } from '@4fu/arbor-prompt';
45
+ import { ARBOR_PRESET, ArborAgentRegistry } from "./registry.js";
46
+ import { ArborSessionAgent } from "./session-agent.js";
47
+ import { inboxProjectionDefinition } from "./inbox.js";
48
+ import { relayToolCalls, windowToolCalls } from "./relay.js";
49
+ /** One session's arbor world: its window, its bridge, its window agent. */
50
+ class SessionArbor {
51
+ window;
52
+ windowAgent;
53
+ bridgeCache;
54
+ /** The session's write handle onto its durable log, once persistence is mounted. */
55
+ stored;
56
+ agent;
57
+ /** The create/resume options a re-route replays, so a session keeps its own. */
58
+ agentOptions;
59
+ agentSetup;
60
+ driver;
61
+ constructor(driver, agent, agentOptions, agentSetup) {
62
+ this.driver = driver;
63
+ this.agent = agent;
64
+ this.agentOptions = agentOptions;
65
+ this.agentSetup = agentSetup;
66
+ }
67
+ /** Hand this world the session's write handle, once it has one. */
68
+ keepStored(stored) {
69
+ this.stored = stored;
70
+ }
71
+ /**
72
+ * End this session's arbor world: unbind its window, unwind the agent's own
73
+ * scope — which detaches the agent and its session and closes the bridge — and
74
+ * release the session's write handle so another driver can open the log.
75
+ *
76
+ * This is the teardown a re-route needs in either direction, and it is the
77
+ * mirror of the embedded loop's own `AgentHandle.dispose`: the window itself is
78
+ * the host's and lives out arbor's own idle policy, so only the binding goes.
79
+ */
80
+ async dispose() {
81
+ await this.agent.scope.dispose();
82
+ await this.stored?.handle.close().catch(() => { });
83
+ this.stored = undefined;
84
+ }
85
+ /** The one Arbor host of this process, and the backend every window runs through. */
86
+ get hostService() {
87
+ return this.driver.hostService;
88
+ }
89
+ /** The window this session's work runs in, once it has one. */
90
+ get windowRef() {
91
+ return this.window;
92
+ }
93
+ /** Whether the window this session holds is still the host's. */
94
+ isLive(window) {
95
+ if (window === undefined)
96
+ return false;
97
+ return this.hostService.host.live().some(record => record.window === window && record.state !== 'ended');
98
+ }
99
+ /** Open the window on first use, through arbor's dispatch. */
100
+ async ensureWindow(request) {
101
+ const held = this.window;
102
+ if (held !== undefined && this.isLive(held))
103
+ return held;
104
+ // A window this session already has is taken back, not answered by a second
105
+ // one: a restart continues the session's window under a new ref, and an
106
+ // earlier turn's window is still open while it waits out its idle period.
107
+ const existing = await this.hostService.windowFor(this.agent.session.id);
108
+ const window = existing ?? await this.dispatchWindow(request);
109
+ this.window = window;
110
+ this.hostService.backend.bind(window, this.agent);
111
+ await this.hostService.backend.whenOpen(window);
112
+ await this.registerWindowAgent(window);
113
+ return window;
114
+ }
115
+ /**
116
+ * The user's request, as the window's first board line.
117
+ *
118
+ * A window opens before the turn's input reaches the model, so the board's
119
+ * first title cannot come from the task alone. The user message is already
120
+ * on the session log when the task dispatches; use its text, one line and
121
+ * bounded, instead of the driver's internal placeholder. A window with no
122
+ * user text yet falls back to the same neutral description.
123
+ */
124
+ requestText() {
125
+ for (const event of this.agent.session.snapshotEvents().toReversed()) {
126
+ if (event.type !== 'user/message')
127
+ continue;
128
+ const content = event.data.content ?? [];
129
+ const text = content
130
+ .filter(block => block.type === 'text')
131
+ .map(block => block.text ?? '')
132
+ .join(' ')
133
+ .replace(/\s+/g, ' ')
134
+ .trim();
135
+ if (text !== '')
136
+ return text.length > 120 ? `${text.slice(0, 119)}…` : text;
137
+ }
138
+ return 'DeepSeek Harness session turn';
139
+ }
140
+ /** Dispatch this session's task, which opens the window that takes it. */
141
+ async dispatchWindow(request) {
142
+ const session = this.agent.session;
143
+ const reason = request === undefined ? this.requestText() : request.replace(/\s+/g, ' ').trim();
144
+ const task = {
145
+ kind: 'batch',
146
+ reason: reason.length > 120 ? `${reason.slice(0, 119)}…` : reason,
147
+ targets: [sessionTarget(session.id)],
148
+ // The label is what the host carries onto every window this task opens,
149
+ // its continuations and the journal's record of both. A read-only surface
150
+ // finds a session's windows by it, with no map of its own.
151
+ label: session.id,
152
+ time: Date.now(),
153
+ content: [dataBlock(`dsh session ${session.id}`)],
154
+ explicit: true,
155
+ source: 'dsh',
156
+ };
157
+ const result = await this.hostService.host.dispatch(task);
158
+ if (result.window === null) {
159
+ throw new Error(`arbor driver: dispatch opened no window for session "${session.id}" (route ${result.route})`);
160
+ }
161
+ return result.window;
162
+ }
163
+ /**
164
+ * Register the window as a DSH agent with `meta.origin: 'subagent'`, the
165
+ * convention `packages/subagent/subagent/src/child-agent.ts` uses for
166
+ * delegated children: the registry and the agent-team tools see the window,
167
+ * and the sidebar's `sessionVisible`
168
+ * (packages/client/ui-workspace/src/client/tree.ts) leaves it out. The agent
169
+ * is owned by this session's agent, so it is not a registry root.
170
+ */
171
+ async registerWindowAgent(window) {
172
+ if (this.windowAgent !== undefined)
173
+ return;
174
+ const sessionId = brandString(`window-${window}-${randomUUID()}`);
175
+ const { agent } = await this.driver.driverContext.agents.create({
176
+ sessionId,
177
+ parentAgent: this.agent,
178
+ meta: {
179
+ origin: 'subagent',
180
+ parentSession: this.agent.session.id,
181
+ // The window works in the arbor shared folder, so its hidden agent's cwd
182
+ // is that folder rather than the DSH process's own directory.
183
+ cwd: this.hostService.sharedFolder,
184
+ },
185
+ agentOptions: {},
186
+ });
187
+ this.windowAgent = agent;
188
+ }
189
+ /** The agent registered for this session's window, once one exists. */
190
+ get windowAgentOf() {
191
+ return this.windowAgent;
192
+ }
193
+ /** The bridge the session agent drives. */
194
+ bridge() {
195
+ this.bridgeCache ??= {
196
+ ensureWindow: request => this.ensureWindow(request),
197
+ systemPrompt: window => this.hostService.backend.systemPrompt(window),
198
+ modelCall: input => this.hostService.backend.modelCall({ ...input, agent: this.agent }),
199
+ runToolCall: async (call, position) => {
200
+ const window = await this.ensureWindow();
201
+ const control = this.hostService.host.windowControl();
202
+ const args = safeArgs(call.arguments);
203
+ const at = await control.recordToolCall(window, { name: call.name, args, callId: call.id });
204
+ // The call is mirrored onto the session log here, for every tool the
205
+ // window runs — arbor's own and DSH's alike. The log is the history the
206
+ // next model request is derived from, and a provider refuses a history
207
+ // whose assistant message asks for a tool call that has no result, so a
208
+ // window that runs only arbor's own tools still leaves a valid log.
209
+ // DSH's pipeline is not asked to log (the external tools carry no log
210
+ // position), which is what keeps this the one pair per call.
211
+ const callSeq = this.agent.session.append('tool/call', {
212
+ ...position,
213
+ callId: call.id,
214
+ name: call.name,
215
+ arguments: call.arguments,
216
+ }).seq;
217
+ const result = await control.runToolCall(window, { name: call.name, args, callId: call.id }, at ?? undefined);
218
+ this.agent.session.append('tool/result', {
219
+ ...position,
220
+ message: createToolResultMessage({
221
+ callId: call.id,
222
+ // Arbor 2026.1107.1 fills a refusal's wording into `content` as well
223
+ // as into `error` — the rule, the message and the next action, in the
224
+ // one form the journal records a refusal with — so the result is
225
+ // written through as it stands. DSH's own tools put a denial's
226
+ // reason in the content too, and without this the model would read
227
+ // an empty tool result and never learn why a call was refused.
228
+ content: [{ type: 'text', text: result.content }],
229
+ isError: !result.ok,
230
+ }),
231
+ ...result.ok ? {} : { error: toolErrorOf(result) },
232
+ }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] });
233
+ return result;
234
+ },
235
+ publish: (window, event) => { this.hostService.backend.publish(window, event); },
236
+ flushSends: () => { this.hostService.flushSends(); },
237
+ finishRun: window => { this.hostService.backend.finishRun(window); },
238
+ close: async () => {
239
+ // The window is the host's, not this session's: it stays open for the
240
+ // session's next turn and ends on arbor's own idle policy, exactly like
241
+ // a window whose model went quiet. Only the binding is dropped.
242
+ if (this.window !== undefined)
243
+ this.hostService.backend.unbind(this.window);
244
+ },
245
+ };
246
+ return this.bridgeCache;
247
+ }
248
+ }
249
+ /** The arbor agent factory and Cordis plugin. */
250
+ export class ArborDriver extends Service {
251
+ static inject = ['agents', 'sessions', 'sessionProjections', 'arborHost', 'tools', 'systemPrompt'];
252
+ static Config = z.object({});
253
+ config;
254
+ /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
255
+ runtime;
256
+ sessions = new Map();
257
+ /**
258
+ * Sessions the embedded AgentLoop is running, with the handle its creation
259
+ * returned. The driver created every one of them — the registry routes every
260
+ * session through this factory — so it holds the only thing that can end one:
261
+ * that is what lets a preset selection re-route a session that has not started
262
+ * a turn. An ordinary session is never touched unless its user picks arbor.
263
+ */
264
+ embedded = new Map();
265
+ modelProjectionHandle;
266
+ inboxProjectionHandle;
267
+ constructor(ctx, config = {}) {
268
+ super(ctx, 'arborDriver');
269
+ this.config = config;
270
+ this.runtime = { ctx };
271
+ }
272
+ async [Service.init]() {
273
+ const ctx = this.runtime.ctx;
274
+ // Refcounted alongside the embedded AgentLoop's own registrations of the
275
+ // same keys (SessionProjectionRegistry.register shares one definition per
276
+ // key), so an arbor agent reads its projections with or without the
277
+ // official loop mounted.
278
+ this.modelProjectionHandle = await ctx.sessionProjections.register(modelSelectionProjectionDefinition);
279
+ this.inboxProjectionHandle = await ctx.sessionProjections.register(inboxProjectionDefinition);
280
+ ctx.effect(() => ctx.agents.useArborFactory(this), 'arborDriver.useArborFactory()');
281
+ // The seam that makes picking Arbor in a composer actually run arbor. DSH's
282
+ // own preset registry commits a blank session's pick by appending
283
+ // `agent-preset/selected` and re-mounting the session's composition, which is
284
+ // all a preset whose presets share one driver needs; a preset that changes the
285
+ // driver has to act on the same event. The log event is observed rather than
286
+ // the registry's own `agent-preset/selected` emit, because that one is
287
+ // dispatched on the registry's context and reaches nobody outside it, while
288
+ // `session/event` is the session-scoped observer channel every host row can
289
+ // listen on. A session that has started a turn never gets here — the registry
290
+ // refuses that selection before it is committed — so the only case is a
291
+ // session still waiting for its first turn, which is exactly the hero.
292
+ // The event carries the OTHER direction too, and it has to be honoured for a
293
+ // session that SELECTED arbor: DSH's session controller re-selects the
294
+ // default preset whenever a blank session is reused (`reuseLive` calls
295
+ // `presets.select` with the resolved default, and `reuseSetup` appends the
296
+ // event itself), so a session the user opened in another workspace is
297
+ // relabelled `standard` while it still runs arbor. Following the event back
298
+ // is what keeps the label and the driver saying the same thing. A session
299
+ // CREATED under arbor is the exception, and the gate is in
300
+ // {@link rerouteToEmbedded}: that session is never switched in place by any
301
+ // surface that knows what the user clicked, so the only selection that can
302
+ // reach it is the controller's default.
303
+ ctx.on('session/event', (session, event) => {
304
+ if (event.type !== 'agent-preset/selected')
305
+ return;
306
+ const selected = event.data.agentPreset;
307
+ if (selected === ARBOR_PRESET) {
308
+ void this.rerouteToArbor(session.id).catch((error) => {
309
+ this.runtime.ctx.logger.warn(`arbor driver: re-routing session ${session.id} to arbor failed: ${error instanceof Error ? error.message : String(error)}`);
310
+ });
311
+ return;
312
+ }
313
+ void this.rerouteToEmbedded(session.id, typeof selected === 'string' ? selected : undefined).catch((error) => {
314
+ this.runtime.ctx.logger.warn(`arbor driver: re-routing session ${session.id} off arbor failed: ${error instanceof Error ? error.message : String(error)}`);
315
+ });
316
+ });
317
+ }
318
+ /**
319
+ * Re-route one session from the embedded AgentLoop to arbor.
320
+ *
321
+ * The loop's agent is ended with the handle its creation returned — which
322
+ * unregisters the agent and detaches the session from the store, leaving the
323
+ * durable log exactly as it stands — and the session is then opened again
324
+ * under arbor, the way a restart opens it. The session keeps its identity, its
325
+ * log and every event it already committed, so the composer that is bound to
326
+ * it keeps working; what changes is which driver its next turn runs.
327
+ */
328
+ async rerouteToArbor(sessionId) {
329
+ const hold = this.embedded.get(sessionId);
330
+ if (hold === undefined)
331
+ return;
332
+ this.embedded.delete(sessionId);
333
+ // A session that has started a turn keeps the driver it began with. DSH's
334
+ // preset registry refuses such a selection before it is committed, so this
335
+ // is a guard rather than a path; it is here because the alternative —
336
+ // silently keeping the old driver — is the one thing the owner ruled out.
337
+ const boundary = this.runtime.ctx.agents.turnBoundaryOf(hold.handle.agent.session);
338
+ if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) {
339
+ this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} selected arbor after its first turn and keeps the embedded loop`);
340
+ return;
341
+ }
342
+ // The re-routed session keeps the cwd it was created with — the directory the
343
+ // user picked for it in DSH's own project picker — and arbor works in the
344
+ // shared folder, so the picked directory is what the pointer names. It is
345
+ // read before the embedded agent is ended, and recorded once, at the moment
346
+ // the session becomes an arbor session: that is the ask, and it is not
347
+ // repeated on a later resume.
348
+ const requested = hold.handle.agent.session.header.cwd;
349
+ const cwd = await this.hostService.resolveArborCwd(requested);
350
+ await hold.handle.dispose();
351
+ await this.resumeArbor(this.runtime.ctx, {
352
+ resumeSessionId: sessionId,
353
+ agentOptions: hold.agentOptions,
354
+ setup: hold.setup,
355
+ cwd,
356
+ });
357
+ this.noteExternalProject(requested, sessionId);
358
+ // The session kept its identity and its log, but its cwd is now arbor's own
359
+ // directory, so it moves to the workspace that owns that directory: DSH
360
+ // filters a workspace's members by the session's stored cwd, and a session
361
+ // left in the workspace it was created in reads as a member of nowhere —
362
+ // which is what leaves the hero composer with no workspace to name.
363
+ await this.hostService.attachArborWorkspace(sessionId, cwd);
364
+ }
365
+ /**
366
+ * Record a directory an arbor session named as an external pointer for that
367
+ * session, when the user had added the directory through DSH's own
368
+ * add-workspace action.
369
+ *
370
+ * The host answers whether the directory was added, because that is a fact
371
+ * about the project list rather than about this session; a directory that was
372
+ * never added — a cwd the session inherited — is left alone, which is the
373
+ * mistake the earlier resolve-time recording made.
374
+ */
375
+ noteExternalProject(requested, sessionId) {
376
+ if (requested === undefined)
377
+ return;
378
+ try {
379
+ if (this.hostService.isAddedArborDirectory(requested)) {
380
+ this.hostService.pointerArborProject(requested, sessionId);
381
+ }
382
+ }
383
+ catch (error) {
384
+ this.runtime.ctx.logger.warn(`arbor driver: recording an external pointer for session ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
385
+ }
386
+ }
387
+ /**
388
+ * Re-route one session from arbor back to the embedded AgentLoop.
389
+ *
390
+ * The mirror of {@link rerouteToArbor}, and it exists for one case only: a
391
+ * session that SELECTED arbor while it was blank can select something else
392
+ * again while it is still blank, and the label and the driver under it must
393
+ * say the same thing. That is the SDK or remote `agent-preset/select` path —
394
+ * every surface that knows what the user clicked creates the session under
395
+ * the picked preset instead (the hero mint, the adopt at create), so nothing
396
+ * in the web profile switches a blank in place. The arbor agent is ended with
397
+ * the handle this driver holds, the window keeps running out arbor's own idle
398
+ * policy, and the session is opened again on the embedded loop with the same
399
+ * id, the same log and the same cwd it was created with.
400
+ *
401
+ * **A session created under arbor is never moved off it here.** Its creation
402
+ * header says `agentPreset: 'arbor'`, which is a frozen creation fact
403
+ * (`@deepseek-ai/dsh-agent-preset-registry`, `lib/types/session.js`), and the
404
+ * one selection that can still arrive on it is DSH's own: the session
405
+ * controller re-selects the resolved deployment default whenever a blank is
406
+ * reused with no preset named — `reuseLive` calls `presets.select` with
407
+ * `composition.agentPreset` and `reuseSetup` appends the event itself
408
+ * (`@deepseek-ai/dsh-api-session-controller`, `lib/index.js`). The log cannot
409
+ * tell that re-select from a user's pick, because `presets.select` is the
410
+ * single writer of `agent-preset/selected` (`lib/index.js`), so the
411
+ * attribution has to come from the header instead: a session created as arbor
412
+ * keeps arbor, and its label is put back through the registry's own select so
413
+ * the projection and the driver agree ({@link reassertArbor}). The client side
414
+ * of the same guarantee is that the restore names the recorded preset, so the
415
+ * re-select is never written in the first place — this gate is what stands
416
+ * behind it.
417
+ *
418
+ * A session that has started a turn keeps the driver it began with: DSH refuses
419
+ * that selection before it is committed, so this is the same defensive guard the
420
+ * arbor direction carries, and the alternative — silently keeping the old driver
421
+ * — is the one outcome the owner ruled out.
422
+ */
423
+ async rerouteToEmbedded(sessionId, selected) {
424
+ const arbor = this.sessions.get(sessionId);
425
+ if (arbor === undefined)
426
+ return;
427
+ if (arbor.agent.session.header.agentPreset === ARBOR_PRESET) {
428
+ this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} was created under arbor and keeps it; `
429
+ + `a selection of ${selected ?? '(unnamed)'} is not followed off a creation header`);
430
+ await this.reassertArbor(arbor);
431
+ return;
432
+ }
433
+ this.sessions.delete(sessionId);
434
+ const boundary = this.runtime.ctx.agents.turnBoundaryOf(arbor.agent.session);
435
+ if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) {
436
+ this.runtime.ctx.logger.warn(`arbor driver: session ${sessionId} selected another preset after its first turn and keeps arbor`);
437
+ return;
438
+ }
439
+ const options = {
440
+ resumeSessionId: sessionId,
441
+ agentOptions: arbor.agentOptions,
442
+ setup: arbor.agentSetup,
443
+ };
444
+ await arbor.dispose();
445
+ await this.resumeEmbedded(this.runtime.ctx, options);
446
+ }
447
+ /**
448
+ * Put a session's label back on arbor, through the registry's own select.
449
+ *
450
+ * The selection being refused has already been appended to the log, so the
451
+ * session's `agentPreset` projection now names another preset while this
452
+ * driver still runs arbor — a label that says one thing while the driver does
453
+ * another, which is the untruth the gate exists to prevent. `presets.select`
454
+ * is the single writer of that event (`@deepseek-ai/dsh-agent-preset-registry`,
455
+ * `lib/index.js`), and it appends only when the projection does not already
456
+ * name the preset, so this writes one honest event and nothing else: the
457
+ * registry's `bind` is a no-op when the agent's context already holds the
458
+ * preset's generation. The event reaches this driver's own observer, which
459
+ * finds the session already running arbor and does nothing.
460
+ */
461
+ async reassertArbor(arbor) {
462
+ const presets = this.runtime.ctx.get('agentPresets');
463
+ if (presets === undefined)
464
+ return;
465
+ try {
466
+ await presets.select(arbor.agent, ARBOR_PRESET);
467
+ }
468
+ catch (error) {
469
+ this.runtime.ctx.logger.warn(`arbor driver: re-asserting arbor on session ${arbor.agent.session.id} failed: `
470
+ + `${error instanceof Error ? error.message : String(error)}`);
471
+ }
472
+ }
473
+ /** The driver's own DSH context, for the per-session arbor worlds. */
474
+ get driverContext() {
475
+ return this.runtime.ctx;
476
+ }
477
+ /** The one Arbor host of this process. */
478
+ get hostService() {
479
+ const host = this.runtime.ctx.get('arborHost');
480
+ if (host === undefined) {
481
+ throw new Error('arbor driver: no arborHost service (load @4fu/dsh-arbor-host)');
482
+ }
483
+ return host;
484
+ }
485
+ /** The per-session arbor world, for tests and diagnostics. */
486
+ sessionArbor(sessionId) {
487
+ return this.sessions.get(sessionId);
488
+ }
489
+ /** The agent registered for a session's arbor window, once one exists. */
490
+ windowAgentOf(sessionId) {
491
+ return this.sessions.get(sessionId)?.windowAgentOf;
492
+ }
493
+ /** The system prompt the session's arbor window rendered, once it opened. */
494
+ systemPromptOf(sessionId) {
495
+ const window = this.sessions.get(sessionId)?.windowRef;
496
+ return window === undefined ? undefined : this.hostService.backend.systemPrompt(window);
497
+ }
498
+ /**
499
+ * Whether a persisted session runs under arbor.
500
+ *
501
+ * The creation header names the preset a session STARTED with and is frozen
502
+ * because it is a creation fact; a session may still select another while it
503
+ * is blank, and DSH's own reconstruction rule for exactly this reason reads
504
+ * the `agentPreset` projection, "never the header alone"
505
+ * (`@deepseek-ai/dsh-agent-preset-registry`, `lib/types/session.js`). So the
506
+ * log's `agent-preset/selected` events are folded over the header's value:
507
+ * that is what makes a re-routed session come back as arbor after a restart
508
+ * instead of silently resuming on the embedded loop.
509
+ */
510
+ async ownsSession(sessionId) {
511
+ const persistence = this.runtime.ctx.get('sessionPersistence');
512
+ if (persistence === undefined)
513
+ return false;
514
+ try {
515
+ const handle = await persistence.open(sessionId, 'read');
516
+ try {
517
+ let preset = handle.header.agentPreset;
518
+ const page = await handle.read(0);
519
+ // The event type is declared by the preset registry's module
520
+ // augmentation; read it structurally so building this package alone does
521
+ // not depend on that augmentation being in the program.
522
+ const events = page.events;
523
+ for (const event of events) {
524
+ if (event.type !== 'agent-preset/selected')
525
+ continue;
526
+ const selected = event.data.agentPreset;
527
+ if (typeof selected === 'string')
528
+ preset = selected;
529
+ }
530
+ return preset === ARBOR_PRESET;
531
+ }
532
+ finally {
533
+ await handle.close();
534
+ }
535
+ }
536
+ catch {
537
+ return false;
538
+ }
539
+ }
540
+ async createAgent(ownerCtx, options) {
541
+ // A session created under the arbor preset runs arbor; every other session
542
+ // runs the embedded AgentLoop. The registry routes both here, because the
543
+ // driver has to hold the embedded handle to be able to re-route a session
544
+ // whose user picks arbor before its first turn.
545
+ if (options.meta?.agentPreset === ARBOR_PRESET)
546
+ return this.createArborAgent(ownerCtx, options);
547
+ const hold = await this.createEmbedded(ownerCtx, options);
548
+ return {
549
+ agent: hold.handle.agent,
550
+ dispose: async () => {
551
+ this.embedded.delete(hold.handle.agent.id);
552
+ await hold.handle.dispose();
553
+ },
554
+ };
555
+ }
556
+ /** Create the agent of a session that runs arbor. */
557
+ async createArborAgent(ownerCtx, options) {
558
+ // An arbor session is not bound to the caller's directory: it works in the
559
+ // arbor shared folder, and a requested directory outside it joins the shared
560
+ // folder only through explicit adoption (see @4fu/dsh-arbor-workspace). The
561
+ // session's cwd is therefore resolved into the shared folder before the
562
+ // session is prepared, so the DSH session, its cwd display and the project
563
+ // picker all name a folder that is really there.
564
+ const requested = options.meta?.cwd;
565
+ const cwd = await this.hostService.resolveArborCwd(requested);
566
+ const meta = options.meta === undefined
567
+ ? { cwd }
568
+ : { ...options.meta, cwd };
569
+ const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
570
+ ...options.seed === undefined ? {} : { seed: options.seed },
571
+ meta,
572
+ ...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount },
573
+ }));
574
+ const id = preparation.session.id;
575
+ // A directory the user added through DSH's own add-workspace action, named by
576
+ // a session that is starting under arbor, is a pointer for that session: the
577
+ // user chose the directory for this session, and arbor's prompt names it so
578
+ // the model can work in the user's own files. A cwd the session merely
579
+ // inherited is not, which is why the question is asked of the host rather
580
+ // than assumed here.
581
+ this.noteExternalProject(requested, id);
582
+ const agent = this.agentFor(preparation.session, options.agentOptions);
583
+ const arbor = new SessionArbor(this, agent, options.agentOptions, options.setup);
584
+ agent.attachBridge(arbor.bridge());
585
+ this.sessions.set(id, arbor);
586
+ let stored;
587
+ try {
588
+ ownerCtx.fiber.assertActive();
589
+ stored = await this.createStoredSession(preparation.session, options.signal);
590
+ arbor.keepStored(stored);
591
+ await this.publish(ownerCtx, arbor, agent, preparation.session, stored, options.setup, 'startup', options.parentAgent, options.signal);
592
+ }
593
+ catch (error) {
594
+ this.sessions.delete(id);
595
+ await arbor.bridge().close();
596
+ await stored?.handle.close().catch(() => { });
597
+ throw error;
598
+ }
599
+ finally {
600
+ preparation[Symbol.dispose]();
601
+ }
602
+ // The session is stored and published, so its header is readable: it now
603
+ // belongs to the workspace that owns the directory it works in, which is
604
+ // what keeps DSH's hero composer live for it.
605
+ await this.hostService.attachArborWorkspace(id, cwd);
606
+ return {
607
+ agent,
608
+ dispose: async () => {
609
+ this.sessions.delete(id);
610
+ await arbor.dispose();
611
+ },
612
+ };
613
+ }
614
+ /**
615
+ * Create an agent for a session the embedded AgentLoop will run.
616
+ *
617
+ * The embedded factory is delegated to exactly the way the base
618
+ * `AgentRegistry` delegates, so an ordinary session's agent, its ownership and
619
+ * its teardown are the official loop's own. The one difference is that this
620
+ * driver keeps the handle: a session whose user later picks arbor is re-routed
621
+ * by ending the loop's agent and opening the session's arbor window, and
622
+ * ending an agent needs the handle its creation returned.
623
+ */
624
+ async createEmbedded(ownerCtx, options) {
625
+ const registry = this.runtime.ctx.agents;
626
+ const factory = registry.embedded;
627
+ if (factory === undefined) {
628
+ throw new Error('no agent factory registered (load an agent-loop plugin)');
629
+ }
630
+ const handle = await delegateFactory(ownerCtx, factory, 'createAgent', options);
631
+ const hold = {
632
+ handle,
633
+ agentOptions: options.agentOptions,
634
+ setup: options.setup,
635
+ };
636
+ this.embedded.set(handle.agent.id, hold);
637
+ return hold;
638
+ }
639
+ /** Resume an agent for a session the embedded AgentLoop will run. */
640
+ async resumeEmbedded(ownerCtx, options) {
641
+ const registry = this.runtime.ctx.agents;
642
+ const factory = registry.embedded;
643
+ if (factory === undefined) {
644
+ throw new Error('no agent factory registered (load an agent-loop plugin)');
645
+ }
646
+ const handle = await delegateFactory(ownerCtx, factory, 'resume', options);
647
+ const hold = { handle, agentOptions: options.agentOptions, setup: options.setup };
648
+ this.embedded.set(handle.agent.id, hold);
649
+ return hold;
650
+ }
651
+ /**
652
+ * Resume a persisted arbor session.
653
+ *
654
+ * The session's log is loaded the way the official loop loads it, with two
655
+ * arbor-specific steps around it: the window chain the journal holds is read
656
+ * back and the `tool/call` events the log lost are replayed onto it, so DSH's
657
+ * own `interruptedTurnClosers` closes them as outcome-unknown rather than
658
+ * not-started; and the session is bound to the window the host continued at
659
+ * boot, so its next turn drives that window instead of opening a new chain.
660
+ */
661
+ async resume(ownerCtx, options) {
662
+ // A persisted session runs arbor when its log says so — the creation header
663
+ // names the preset it started with and a blank session may still have
664
+ // selected another, which is why the log is folded rather than the header
665
+ // read. Everything else runs the embedded AgentLoop, with its handle kept
666
+ // for the same reason `createAgent` keeps one.
667
+ if (await this.ownsSession(options.resumeSessionId)) {
668
+ // A restart resolves the stored cwd the way a creation resolves a requested
669
+ // one, so the session comes back naming the shared folder or the project
670
+ // the user pointed it at — never the directory it happened to be created
671
+ // in before it selected arbor.
672
+ const persistence = this.runtime.ctx.get('sessionPersistence');
673
+ const stored = persistence === undefined ? undefined : await this.storedCwd(persistence, options.resumeSessionId);
674
+ return this.resumeArbor(ownerCtx, {
675
+ ...options,
676
+ cwd: await this.hostService.resolveArborCwd(stored),
677
+ });
678
+ }
679
+ const hold = await this.resumeEmbedded(ownerCtx, options);
680
+ return {
681
+ agent: hold.handle.agent,
682
+ dispose: async () => {
683
+ this.embedded.delete(hold.handle.agent.id);
684
+ await hold.handle.dispose();
685
+ },
686
+ };
687
+ }
688
+ /**
689
+ * The cwd a persisted session was created with, read without taking its write
690
+ * ownership: the resume path needs it to resolve the session's cwd before it
691
+ * opens the log for writing.
692
+ */
693
+ async storedCwd(persistence, sessionId) {
694
+ try {
695
+ const handle = await persistence.open(sessionId, 'read');
696
+ try {
697
+ return handle.header.cwd;
698
+ }
699
+ finally {
700
+ await handle.close();
701
+ }
702
+ }
703
+ catch {
704
+ return undefined;
705
+ }
706
+ }
707
+ /** Resume a persisted session that runs arbor. */
708
+ async resumeArbor(ownerCtx, options) {
709
+ const ctx = this.runtime.ctx;
710
+ const persistence = ctx.get('sessionPersistence');
711
+ if (persistence === undefined) {
712
+ throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)');
713
+ }
714
+ const id = options.resumeSessionId;
715
+ // The directory this session works in, read from its stored header before
716
+ // the log is opened for writing: the resume resolves it the same way a
717
+ // creation does, and the workspace attach below names it.
718
+ let open;
719
+ let preparation;
720
+ let stored;
721
+ let agent;
722
+ let arbor;
723
+ let cwd = options.cwd;
724
+ try {
725
+ open = await persistence.open(id, 'write', {
726
+ ...options.signal === undefined ? {} : { signal: options.signal },
727
+ });
728
+ cwd ??= open.header.cwd;
729
+ const coldRead = await open.read(0);
730
+ const windows = await this.hostService.windowChainOf(id);
731
+ const recorded = await windowToolCalls(this.hostService.journal, windows);
732
+ const relayed = relayToolCalls(coldRead.events, recorded);
733
+ if (relayed.length > 0)
734
+ await open.append(relayed);
735
+ const closers = interruptedTurnClosers([...coldRead.events, ...relayed]);
736
+ if (closers.length > 0)
737
+ await open.append(closers);
738
+ const seed = [...coldRead.events, ...relayed, ...closers];
739
+ preparation = SessionPreparation.create(ctx.sessions.prepare(id, {
740
+ seed,
741
+ // The cwd a persisted session resumes with is resolved into the shared
742
+ // folder exactly as a creation resolves it, so a session created under
743
+ // arbor, one re-routed onto it and one resumed after a restart all name
744
+ // the same directory: the shared folder, or the project subfolder the
745
+ // user pointed the session at. The stored header keeps what creation
746
+ // wrote; the live session is what the model, the cwd display and the
747
+ // project line all read.
748
+ meta: { ...structuredClone(open.header), cwd: options.cwd ?? open.header.cwd },
749
+ inheritedEventCount: open.inheritedEventCount,
750
+ eventState: coldRead.eventState,
751
+ }));
752
+ stored = { handle: open, storedCount: seed.length };
753
+ open = undefined;
754
+ await this.appendUnstoredSuffix(stored, preparation.session);
755
+ agent = this.agentFor(preparation.session, options.agentOptions);
756
+ arbor = new SessionArbor(this, agent, options.agentOptions, options.setup);
757
+ agent.attachBridge(arbor.bridge());
758
+ this.sessions.set(id, arbor);
759
+ arbor.keepStored(stored);
760
+ await this.publish(ownerCtx, arbor, agent, preparation.session, stored, options.setup, 'resume', options.parentAgent, options.signal);
761
+ }
762
+ catch (error) {
763
+ if (agent !== undefined)
764
+ this.sessions.delete(id);
765
+ await arbor?.bridge().close();
766
+ await stored?.handle.close().catch(() => { });
767
+ throw error;
768
+ }
769
+ finally {
770
+ preparation?.[Symbol.dispose]();
771
+ await open?.close().catch(() => { });
772
+ }
773
+ // The session is back and its log is open for writing, so its header is
774
+ // readable: it belongs to the workspace that owns the directory it works in,
775
+ // the same one it belonged to before the restart.
776
+ if (cwd !== undefined)
777
+ await this.hostService.attachArborWorkspace(id, cwd);
778
+ const live = { agent, arbor };
779
+ return {
780
+ agent: live.agent,
781
+ dispose: async () => {
782
+ this.sessions.delete(id);
783
+ await live.arbor?.dispose();
784
+ },
785
+ };
786
+ }
787
+ /** Build the session agent for one prepared session, create or resume. */
788
+ agentFor(session, options) {
789
+ return new ArborSessionAgent(this.runtime.ctx, session.id, options ?? {}, session, {
790
+ modelSelection: this.modelProjectionHandle.readerFor(session),
791
+ inbox: this.inboxProjectionHandle.readerFor(session),
792
+ }, this.runtime.ctx.agents.turnBoundaryOf(session).lastTurn);
793
+ }
794
+ /** Take a fresh session's write ownership when persistence is mounted. */
795
+ async createStoredSession(session, signal) {
796
+ const persistence = this.runtime.ctx.get('sessionPersistence');
797
+ if (persistence === undefined)
798
+ return undefined;
799
+ const handle = await persistence.create(session.header, {
800
+ inheritedEventCount: session.inheritedEventCount,
801
+ ...signal === undefined ? {} : { signal },
802
+ });
803
+ return { handle, storedCount: 0 };
804
+ }
805
+ /** Durably store the session events appended since the last stored cursor. */
806
+ async appendUnstoredSuffix(stored, session) {
807
+ if (stored === undefined)
808
+ return;
809
+ const suffix = session.snapshotEvents(stored.storedCount);
810
+ if (suffix.length > 0)
811
+ await stored.handle.append(suffix);
812
+ stored.storedCount += suffix.length;
813
+ }
814
+ /** Enter and announce the session and the agent, exactly like AgentLoop does. */
815
+ async publish(ownerCtx, arbor, agent, session, stored, setup, source, parentAgent, signal) {
816
+ const loopCtx = this.runtime.ctx;
817
+ await agent.runMaintenance(async () => {
818
+ if (stored !== undefined) {
819
+ const suffix = session.snapshotEvents(stored.storedCount);
820
+ if (suffix.length > 0)
821
+ await stored.handle.append(suffix);
822
+ stored.storedCount += suffix.length;
823
+ }
824
+ const detachSession = agent.ctx.sessions.enter(session);
825
+ agent.ctx.sessions.announce(session);
826
+ // Every capability appears once: the DSH tools arbor's own replaces are
827
+ // denied in this session's scope, so DSH's own pipeline refuses them and
828
+ // their `tool:*` prompt sections render empty. The same list keeps them
829
+ // out of what the backend hands arbor's catalog
830
+ // (`@4fu/dsh-arbor-tools` DSH_TOOLS_ARBOR_REPLACES). The restriction is
831
+ // scoped to this agent — `tools.restrict` refuses a global one — and is
832
+ // disposed with the agent's own context.
833
+ const liftRestriction = agent.ctx.tools.restrict({
834
+ deny: replacedToolsRegistered(loopCtx.tools),
835
+ });
836
+ const commit = await setup?.(agent.ctx, agent);
837
+ commit?.commit();
838
+ // A `tool:*` section's text reads whether its tool is registered, and the
839
+ // restriction moves that without a registration change, so the prompt
840
+ // bridge is told the prompt moved.
841
+ this.hostService.noteDshPromptChange();
842
+ const detachAgent = loopCtx.agents.enter(agent, parentAgent);
843
+ await loopCtx.agents.announce(agent, source, signal);
844
+ agent.ctx.effect(function* () {
845
+ yield () => {
846
+ detachAgent();
847
+ detachSession();
848
+ };
849
+ yield async () => { await arbor.bridge().close(); };
850
+ }, 'arborDriver.detach()');
851
+ agent.ctx.effect(() => liftRestriction, 'arborDriver.restrictTools()');
852
+ });
853
+ }
854
+ }
855
+ /**
856
+ * Delegate to one factory exactly the way the base `AgentRegistry` does, so an
857
+ * embedded-loop session's agent, ownership and teardown stay the official loop's
858
+ * own even though this driver is the one calling it.
859
+ */
860
+ function delegateFactory(ownerCtx, factory, method, options) {
861
+ const target = factory[symbols.original] ?? factory;
862
+ const receiver = getTraceable(ownerCtx, target);
863
+ return Reflect.apply(target[method], receiver, [ownerCtx, options]);
864
+ }
865
+ /** Parse a model-produced arguments string; invalid JSON becomes an empty object. */
866
+ function safeArgs(raw) {
867
+ try {
868
+ const parsed = JSON.parse(raw);
869
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
870
+ ? parsed
871
+ : {};
872
+ }
873
+ catch {
874
+ return {};
875
+ }
876
+ }
877
+ /** One refused tool call as the session log's error shape. */
878
+ function toolErrorOf(result) {
879
+ return {
880
+ name: result.error?.rule ?? 'ToolCallFailed',
881
+ code: 'TOOL_CALL_FAILED',
882
+ ...result.error === undefined ? {} : { reason: result.error.message },
883
+ };
884
+ }
885
+ export default ArborDriver;