@4fu/dsh-arbor-host 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/index.js ADDED
@@ -0,0 +1,898 @@
1
+ /**
2
+ * The one Arbor host a DSH process runs.
3
+ *
4
+ * Arbor's point is one journal, one note store and one board across many
5
+ * sources, so a DSH process mounts exactly one `Arbor` host and every DSH user
6
+ * session is one source (one conversation target) of it. This row owns that
7
+ * host:
8
+ *
9
+ * - the durable storage under `DSH_HOME/arbor/` — `@4fu/arbor-journal-sqlite`
10
+ * for the journal and `@4fu/arbor-notes-sqlite` for the note store, both on
11
+ * `node:sqlite`, so nothing native has to build and an arbor window survives
12
+ * a DSH restart (the host rebuilds every running window from the journal in
13
+ * `Arbor.start`);
14
+ * - the DSH-backed `AgentBackend` (`@4fu/dsh-arbor-llm`), which runs each
15
+ * window's turns through `ctx.llm` and each tool call through
16
+ * `ctx.tools.execute`;
17
+ * - the two prompt plugins (`@4fu/dsh-arbor-prompt`): a trusted `<harness>`
18
+ * section this bridge writes, and a `<dsh>` data block for third-party
19
+ * material;
20
+ * - the deployment's persona, read out of DSH's own assembly once and merged
21
+ * into arbor's `<identity>` section, because it is the deployment's
22
+ * configuration rather than a section of its own;
23
+ * - the environment (`@4fu/dsh-arbor-env`): arbor's own under
24
+ * `DSH_HOME/arbor/env`, with DSH's skills and MCP servers mounted read-only
25
+ * beside them;
26
+ * - the workspace (`@4fu/dsh-arbor-workspace`): arbor's own, over the shared
27
+ * folder under `DSH_HOME/arbor` — ovnfs when its daemon starts, the plain
28
+ * directory workspace otherwise. For arbor sessions there is no DSH sandbox:
29
+ * arbor's tools run on the host the way they do in standalone arbor, and the
30
+ * only protection is arbor's own secret scrub;
31
+ * - the project bridge (`@4fu/dsh-arbor-workspace`): the shared folder's
32
+ * project subfolders are registered in DSH's own `workspaceRegistry`, so
33
+ * arbor's projects are the DSH project list a user picks from;
34
+ * - the `send` executor (`./send.ts`): a window's `send` lands on the DSH
35
+ * session log as an assistant message, which is the only surface the user
36
+ * reads;
37
+ * - the settings, whose window block is left to arbor's own defaults: a window
38
+ * stays open 300 s after its run ends and the next input for the session
39
+ * opens the next window of its chain.
40
+ *
41
+ * The judge is off on purpose in B1: every judge decision is model-based, and
42
+ * a model-based judge cannot run through the DSH backend yet. With the judge
43
+ * off, `host.dispatch` is deterministic — one window per session target,
44
+ * opened on the session's first turn — which is the routing policy B1 ships.
45
+ *
46
+ * There is no `workspaces.retentionMs` here: ovnfs checkouts are a window's own
47
+ * work, and a chain that ends leaves them where they are until a retention
48
+ * period is set, exactly as arbor's own defaults do. Nothing here is reclaimed
49
+ * by default, and the shared folder's projects are never removed — they are the
50
+ * user's work, not arbor's to delete.
51
+ */
52
+ import { mkdir } from 'node:fs/promises';
53
+ import { join } from 'node:path';
54
+ import { Service } from '@deepseek-ai/cordis';
55
+ import z from '@deepseek-ai/schemastery';
56
+ import { MAX_PAGE_LIMIT } from '@4fu/arbor-interfaces';
57
+ import { Arbor } from '@4fu/arbor-core';
58
+ import { SqliteJournal } from '@4fu/arbor-journal-sqlite';
59
+ import { SqliteNoteStore } from '@4fu/arbor-notes-sqlite';
60
+ import { DirectoryEnvironment } from '@4fu/arbor-environment-dir';
61
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
62
+ import { planProjectionDefinition } from '@deepseek-ai/dsh-plan-mode';
63
+ import { createDshBackend } from '@4fu/dsh-arbor-llm';
64
+ import { dshPromptPlugins, interpolatePersona, readDeploymentPersona, } from '@4fu/dsh-arbor-prompt';
65
+ import { DshEnvironment, dshSkillSource } from '@4fu/dsh-arbor-env';
66
+ import { openArborWorkspace, arborSharedFolder, DshProjectBridge } from '@4fu/dsh-arbor-workspace';
67
+ import { DshSendExecutor } from "./send.js";
68
+ export { DshSendExecutor, DSH_TARGET_PREFIX, DSH_USER_TARGET } from "./send.js";
69
+ /**
70
+ * The settings this deployment runs arbor with.
71
+ *
72
+ * The identity is this bridge's own sentence, in Chinese like every other
73
+ * model-facing line dsh-arbor writes, and it says only what is true and not
74
+ * already said: `<harness>` states that the window runs inside DeepSeek
75
+ * Harness and that the user sees only what `send` writes, and arbor's own
76
+ * `<agent>` rules state that a window is disposable and that its chain
77
+ * continues. Nothing here repeats either, and nothing here claims one DSH
78
+ * session is one arbor window — several windows serve one source and each ends
79
+ * on its own.
80
+ *
81
+ * `generalRules` is deliberately absent: arbor's own defaults are the seven
82
+ * rules every window needs, in Chinese, and B0's English replacements dropped
83
+ * them and restated two of them wrongly.
84
+ *
85
+ * The window block is deliberately absent too: arbor's own defaults apply
86
+ * (300 s idle, 200 000 context tokens, 4 000 reserved), so a window that goes
87
+ * quiet ends and the session's next turn opens the next window of its chain.
88
+ *
89
+ * The workspaces block is deliberately absent as well: the workspace is arbor's
90
+ * own over the shared folder, ovnfs checkouts are a window's own work, and the
91
+ * shared folder's projects are never reclaimed — so with no retention period
92
+ * nothing is removed.
93
+ */
94
+ export function defaultSettings(extra) {
95
+ return {
96
+ identity: '你是 dsh-arbor 部署里的一个 arbor 窗口:DeepSeek Harness(DSH)是外壳,arbor 是里面的 agent 框架,你替 DSH 的用户干活。',
97
+ judge: {
98
+ modes: {
99
+ filter: 'off',
100
+ route: 'off',
101
+ model_tier: 'off',
102
+ notice: 'off',
103
+ broadcast: 'off',
104
+ active_set: 'off',
105
+ },
106
+ },
107
+ ...extra,
108
+ };
109
+ }
110
+ /** The target prefix that names the DSH session a window belongs to. */
111
+ export const SESSION_TARGET_PREFIX = 'dsh:session:';
112
+ /** The conversation target one DSH user session is, for arbor's dispatch. */
113
+ export function sessionTarget(sessionId) {
114
+ return `${SESSION_TARGET_PREFIX}${sessionId}`;
115
+ }
116
+ /** The DSH session a conversation target names, or undefined for any other target. */
117
+ export function sessionOfTarget(target) {
118
+ return target.startsWith(SESSION_TARGET_PREFIX)
119
+ ? target.slice(SESSION_TARGET_PREFIX.length)
120
+ : undefined;
121
+ }
122
+ /**
123
+ * The plugin that records which DSH session each window serves.
124
+ *
125
+ * Arbor's `windowStart` hook runs before the `window_start` event is appended
126
+ * and before the window's prompt epoch is rendered, and it carries the window
127
+ * ref together with the task's targets — the only moment at which a window can
128
+ * be attributed to a session before its prefix is built. A prompt section that
129
+ * answers per session (the external-project pointers, the project line) is
130
+ * rendered for a window, and a window's prefix is frozen when it opens, so the
131
+ * attribution has to exist by then: reading it later would put those lines in a
132
+ * delta of the window's opening instead of in the prefix it opens with.
133
+ *
134
+ * The `windowContinue` hook is the same moment for a continuation window, which
135
+ * carries no targets of its own — a continuation continues its predecessor's
136
+ * work, so it serves the session that window served. Without it a window the
137
+ * host continues (the hard limit, an idle handover, a restart's recovery) opens
138
+ * with a prefix that names no session and loses both per-session lines.
139
+ *
140
+ * The hooks observe and change nothing, so a window they cannot attribute — one
141
+ * dispatched from outside a DSH session — opens exactly as it would without them.
142
+ *
143
+ * @param note - called with the window and the session it serves.
144
+ * @param sessionOf - the session a window already recorded serves, for a
145
+ * continuation to inherit.
146
+ */
147
+ export function windowSessionPlugin(note, sessionOf) {
148
+ return {
149
+ name: 'dsh-arbor-window-session',
150
+ hooks: {
151
+ windowStart: (context) => {
152
+ const label = context.task.label;
153
+ if (label !== undefined && label !== '') {
154
+ note(context.window, label);
155
+ }
156
+ else {
157
+ // An unlabeled task from an SDK or a remote integration still carries
158
+ // its session target; the label is the deployment's own attachment.
159
+ for (const target of context.task.targets) {
160
+ const sessionId = sessionOfTarget(target);
161
+ if (sessionId !== undefined)
162
+ note(context.window, sessionId);
163
+ }
164
+ }
165
+ return { allow: true, context };
166
+ },
167
+ windowContinue: (context) => {
168
+ const inherited = sessionOf(context.previous);
169
+ if (inherited !== undefined)
170
+ note(context.window, inherited);
171
+ return { allow: true, context };
172
+ },
173
+ },
174
+ };
175
+ }
176
+ /** The window a `window_start` continues, when it continues one. */
177
+ function continuedWindow(body) {
178
+ if (body === null)
179
+ return undefined;
180
+ try {
181
+ const parsed = JSON.parse(body);
182
+ const previous = parsed?.continuation;
183
+ return typeof previous === 'string' ? previous : undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
189
+ /** The opaque label a `window_start` carries, or the empty string. */
190
+ function readLabel(event) {
191
+ if (event.body === null)
192
+ return '';
193
+ try {
194
+ const parsed = JSON.parse(event.body);
195
+ const label = parsed?.label;
196
+ return typeof label === 'string' ? label : '';
197
+ }
198
+ catch {
199
+ return '';
200
+ }
201
+ }
202
+ /** The one Arbor host per DSH process, and the storage it runs on. */
203
+ export class ArborHostService extends Service {
204
+ // What the host and the pieces it mounts read: the backend's LLM and tools,
205
+ // the registry that names the session whose turn is running, DSH's prompt
206
+ // assembly and DSH's skill registry for `/env`. The workspace is arbor's own
207
+ // (ovnfs or the plain directory over the shared folder), so it needs neither
208
+ // DSH's filesystem/shell seams nor the sandbox policy — those stay mounted for
209
+ // ordinary sessions on the embedded AgentLoop, and arbor's windows simply do
210
+ // not route through them.
211
+ static inject = [
212
+ 'llm', 'tools', 'agents', 'systemPrompt', 'sessionProjections', 'skills',
213
+ ];
214
+ static Config = z.object({});
215
+ config;
216
+ state;
217
+ /**
218
+ * Which DSH session each window serves, written by the `windowStart` hook
219
+ * before the window's prefix is built. Arbor's own `WindowRef` carries no
220
+ * session identity, and a per-session prompt section is rendered for a window,
221
+ * so this is the mapping that section reads.
222
+ */
223
+ windowSessions = new Map();
224
+ /**
225
+ * The stored cwd of a session whose agent is not live, read once. A session's
226
+ * stored header never moves, so the answer is remembered; the live projection
227
+ * takes over the moment the session is back.
228
+ */
229
+ storedCwds = new Map();
230
+ /** Raised whenever a DSH prompt provider changes, so both sections follow. */
231
+ promptChanges = 0;
232
+ /** Plan mode's projection, so the `<harness>` section can read it per step. */
233
+ planProjection;
234
+ /** The deployment's persona, read once when the host is built. */
235
+ persona;
236
+ /** The last provider and model a live session rendered under, for the fallback. */
237
+ lastProvider;
238
+ lastModel;
239
+ /** The warnings the bridge raised while the host was built, for diagnostics. */
240
+ warnings = [];
241
+ constructor(ctx, config = {}) {
242
+ super(ctx, 'arborHost');
243
+ this.config = config;
244
+ }
245
+ async [Service.init]() {
246
+ const ctx = this.ctx;
247
+ const root = this.config.root ?? join(resolveDshHome(), 'arbor');
248
+ await mkdir(root, { recursive: true });
249
+ const journal = new SqliteJournal({ path: join(root, 'journal.sqlite') });
250
+ const notes = new SqliteNoteStore({ path: join(root, 'notes.sqlite'), journal });
251
+ // The deployment's persona belongs in arbor's <identity>, so it is read out
252
+ // of DSH's own assembly before the host is built. A persona a plugin
253
+ // registers after this point is not carried; the section it registers is
254
+ // still decided by the table and reported through `warnings`.
255
+ const persona = await readDeploymentPersona(ctx.systemPrompt);
256
+ this.persona = persona;
257
+ for (const warning of persona.warnings) {
258
+ this.warnings.push(warning);
259
+ ctx.logger.warn(warning);
260
+ }
261
+ const settings = defaultSettings(this.config.settings);
262
+ // `identity` is a getter rather than a string because DSH's persona carries
263
+ // `{{model}}` and `{{provider}}`, which are per-request values there: the
264
+ // loop re-resolves them on every request, and arbor's prefix is frozen per
265
+ // epoch, so the only faithful resolution is the one for the session whose
266
+ // turn is rendering. Arbor reads `settings.identity` at each render
267
+ // (`renderSystemPrompt`), so the getter is evaluated per epoch.
268
+ const identity = settings.identity ?? '';
269
+ Object.defineProperty(settings, 'identity', {
270
+ enumerable: true,
271
+ configurable: true,
272
+ get: () => [identity, ...this.personaLines()].filter(part => part !== '').join('\n\n'),
273
+ });
274
+ // The backend is built before the host, because the host is built with it;
275
+ // the renderer is therefore read lazily, and only once a delivery has to be
276
+ // rendered — which is long after the host exists.
277
+ let host;
278
+ const backend = createDshBackend({
279
+ llm: ctx.llm,
280
+ tools: ctx.tools,
281
+ // One backend serves every session's windows, so the agent a tool call
282
+ // runs as is resolved per call: the session whose turn is executing it,
283
+ // which is the initiating agent of that turn (see @4fu/dsh-arbor-tools).
284
+ agents: ctx.agents,
285
+ renderer: () => {
286
+ if (host === undefined)
287
+ throw new Error('arbor host: the renderer was read before the host existed');
288
+ return host.renderer;
289
+ },
290
+ });
291
+ // Arbor's own workspace over the shared folder: ovnfs when its daemon
292
+ // starts, the plain-directory workspace otherwise. For arbor sessions there
293
+ // is no DSH sandbox — arbor's file tools run on the host the way they do in
294
+ // standalone arbor, and arbor's own secret scrub is the only protection on
295
+ // what reaches the model. The `<workspace>` section and the `workspace.*`
296
+ // module functions are the backend's own, with no sandbox line and no
297
+ // `sandbox_escalate`; the external-project pointers are rendered inside that
298
+ // same section, so "where you work" and "where the user's own directories
299
+ // are" are answered in one place.
300
+ // The DSH project bridge: the shared folder's project subfolders become the
301
+ // DSH project list. `workspaceRegistry` is a web-profile service, and it
302
+ // activates AFTER this host does — DSH's own activation order is
303
+ // service-availability driven and this host declares no dependency on it —
304
+ // so it is injected rather than read: on a deployment with no registry
305
+ // (headless, acp, sdk) the child never activates and the bridge stays inert,
306
+ // and on the web profile it attaches the registry and keeps the list live.
307
+ // Reading it once here instead left the bridge permanently inert on the web
308
+ // profile, which is why arbor's projects never reached the picker.
309
+ const projects = new DshProjectBridge({
310
+ sharedFolder: arborSharedFolder(root),
311
+ warn: message => ctx.logger.warn(message),
312
+ });
313
+ this.ctx.inject(['workspaceRegistry'], (child) => {
314
+ child.effect(() => projects.attach(child.get('workspaceRegistry')), 'arborHost.projects()');
315
+ });
316
+ const arborWorkspace = await openArborWorkspace({
317
+ root,
318
+ journal,
319
+ warn: message => ctx.logger.warn(message),
320
+ pointers: {
321
+ // The section is rendered for one window, and the window names the
322
+ // session whose pointers it answers with. The attribution is the
323
+ // `windowStart` hook's, which runs before the window's prefix is built,
324
+ // so the pointer is in the prefix the window opens with rather than in a
325
+ // delta of its own opening. A window nothing attributes (one dispatched
326
+ // from outside a DSH session) names no session, and an unnamed session
327
+ // has no pointers rather than another session's.
328
+ list: (window) => projects.listPointers(window === undefined ? undefined : this.sessionOfWindow(window)),
329
+ // The project the session was opened on, read from the session's own cwd
330
+ // through the window that serves it: the project is a fact about where
331
+ // the user pointed the session, and the cwd is where the driver resolved
332
+ // that to.
333
+ project: (window) => this.projectOfWindow(window),
334
+ revision: () => this.workspaceRevision(),
335
+ },
336
+ });
337
+ // `/env`: arbor's own environment under DSH_HOME/arbor, with DSH's skills
338
+ // and MCP servers mounted read-only beside it.
339
+ const environment = new DshEnvironment({
340
+ inner: new DirectoryEnvironment({ root, supervisor: undefined }),
341
+ skills: dshSkillSource(ctx.skills, listener => ctx.on('skills/change', listener)),
342
+ toolNames: () => ctx.tools.definitions().map(tool => tool.name),
343
+ cwd: () => ctx.agents.currentInitiator()?.session.header.cwd ?? arborWorkspace.sharedFolder,
344
+ seedSettings: settings,
345
+ });
346
+ const sendExecutor = new DshSendExecutor({ journal, agents: ctx.agents });
347
+ host = new Arbor({
348
+ journal,
349
+ notes,
350
+ backend,
351
+ workspace: arborWorkspace.workspace,
352
+ environment,
353
+ plugins: [
354
+ ...dshPromptPlugins({
355
+ systemPrompt: ctx.systemPrompt,
356
+ // The assembly runs in the scope of the session whose turn is rendering
357
+ // the section, which is what lets DSH's own `plan:policy` say whether
358
+ // plan mode is active. Arbor rebuilds an epoch whenever a section's
359
+ // revision moves, and the revision covers this session's state, so two
360
+ // sessions of different states never share one epoch.
361
+ agent: () => ctx.agents.currentInitiator(),
362
+ // A fresh signal per assembly: a section is re-rendered by the host's
363
+ // own watch loop, after the turn that opened the window has ended, and
364
+ // DSH's working-directory context provider does filesystem I/O under
365
+ // this signal — a finished turn's would abort it.
366
+ signal: new AbortController().signal,
367
+ changeCounter: () => this.promptChanges,
368
+ state: () => this.harnessState(),
369
+ onWarning: (message) => {
370
+ this.warnings.push(message);
371
+ ctx.logger.warn(message);
372
+ },
373
+ }),
374
+ windowSessionPlugin((window, sessionId) => { this.windowSessions.set(window, sessionId); }, (window) => this.windowSessions.get(window)),
375
+ ],
376
+ settings,
377
+ executors: [sendExecutor],
378
+ });
379
+ this.state = {
380
+ root,
381
+ journal,
382
+ notes,
383
+ backend,
384
+ host,
385
+ settings,
386
+ workspace: arborWorkspace.workspace,
387
+ environment,
388
+ sharedFolder: arborWorkspace.sharedFolder,
389
+ workspaceBackend: arborWorkspace.backend,
390
+ projects,
391
+ sendExecutor,
392
+ };
393
+ // Plan mode's projection is registered here rather than only in the plan
394
+ // row, because the <harness> section reads it once per step. Registration
395
+ // is refcounted per key, so both see one fold of the same session log.
396
+ this.planProjection = await ctx.sessionProjections.register(planProjectionDefinition);
397
+ // Register the shared folder's projects in DSH's registry and keep the list
398
+ // live, so a project a window creates reaches the UI without a restart. With
399
+ // no registry attached yet — the web profile's activates after this host —
400
+ // both are no-ops here and the injected child above does them instead.
401
+ await projects.sync();
402
+ projects.start();
403
+ // Which session every recorded window serves, rebuilt from the journal
404
+ // before the host starts: the recovery below continues every window the
405
+ // journal still holds open, a continuation carries no targets of its own, and
406
+ // the map the hooks would have written died with the process that wrote it.
407
+ // Reading it here is what keeps the per-session prompt lines — the project
408
+ // line and the pointers — in the prefix a recovered window opens with.
409
+ await this.rebuildWindowSessions();
410
+ // Rebuild every window the journal still holds open: a window a previous
411
+ // process stopped is continued here, the way the hard limit continues one.
412
+ await host.start();
413
+ // Every DSH prompt-provider change moves the `<dsh>` section's revision, so
414
+ // arbor re-renders it and hands the changed lines to live windows.
415
+ ctx.on('system-prompt/change', () => { this.promptChanges += 1; });
416
+ ctx.effect(() => async () => {
417
+ projects.stop();
418
+ // Stopping the host records `host_restart` for every live window, so the
419
+ // next start continues each of them; the stores close after it.
420
+ await host.close().catch(() => { });
421
+ await journal.close().catch(() => { });
422
+ await notes.close().catch(() => { });
423
+ }, 'arborHost.close()');
424
+ }
425
+ /**
426
+ * The dynamic state the `<harness>` section renders, read for the session
427
+ * whose turn is running.
428
+ *
429
+ * A section is frozen per epoch and shared by every window of it, so a
430
+ * session's own policy is the one whose turn is rendering — the same rule
431
+ * arbor's `<workspace>` section follows for its root. A window a restart
432
+ * continued renders outside any turn and therefore without these lines until
433
+ * its session's next turn moves the revision and delivers them as a delta.
434
+ */
435
+ harnessState() {
436
+ const ctx = this.ctx;
437
+ const agent = ctx.agents.currentInitiator();
438
+ const session = agent?.session;
439
+ // The approval policy: the session's own override when it has one, else the
440
+ // deployment's configured default. `effectivePolicy` is DSH-private, and
441
+ // `overrideOf` plus the config is the public half of the same read.
442
+ const approvalService = ctx.get('approval');
443
+ const approval = session === undefined
444
+ ? undefined
445
+ : approvalService?.overrideOf(session) ?? approvalService?.config.policy;
446
+ const planActive = session === undefined ? false : this.planReader(session).active === true;
447
+ return {
448
+ approval,
449
+ planActive,
450
+ exposedTools: this.state?.backend.externalToolNames() ?? [],
451
+ };
452
+ }
453
+ /** The plan-mode state of one session, from the host's own registration. */
454
+ planReader(session) {
455
+ const handle = this.planProjection;
456
+ if (handle === undefined)
457
+ return { active: false };
458
+ return handle.readerFor(session)();
459
+ }
460
+ /**
461
+ * Note that a DSH prompt input changed without a `system-prompt/change`.
462
+ *
463
+ * The driver calls this after it installs the arbor tool restriction in a
464
+ * session's scope: a `tool:*` section's text reads whether its tool is
465
+ * registered, and the restriction moves that without a registration change.
466
+ */
467
+ noteDshPromptChange() {
468
+ this.promptChanges += 1;
469
+ }
470
+ /**
471
+ * The deployment's persona lines, with DSH's prompt variables resolved
472
+ * against the session whose turn is rendering.
473
+ *
474
+ * A window a restart continued renders outside any turn, so the last values
475
+ * a live session gave are the fallback: the persona then names the model that
476
+ * session was running rather than carrying a placeholder.
477
+ */
478
+ personaLines() {
479
+ const agent = this.ctx.agents.currentInitiator();
480
+ const selection = agent?.model.current();
481
+ const provider = selection?.provider ?? agent?.options.provider;
482
+ const model = selection?.model ?? agent?.options.model;
483
+ if (provider !== undefined)
484
+ this.lastProvider = provider;
485
+ if (model !== undefined)
486
+ this.lastModel = model;
487
+ const resolved = {
488
+ ...this.lastProvider === undefined ? {} : { provider: this.lastProvider },
489
+ ...this.lastModel === undefined ? {} : { model: this.lastModel },
490
+ };
491
+ return [this.persona?.prefix ?? '', this.persona?.suffix ?? '']
492
+ .map(text => interpolatePersona(text, resolved))
493
+ .filter(text => text !== '');
494
+ }
495
+ requireState() {
496
+ if (this.state === undefined) {
497
+ throw new Error('arbor host is not started (the @4fu/dsh-arbor-host row has not activated)');
498
+ }
499
+ return this.state;
500
+ }
501
+ /** The one Arbor host of this process. */
502
+ get host() {
503
+ return this.requireState().host;
504
+ }
505
+ /** The durable journal every window of this process records into. */
506
+ get journal() {
507
+ return this.requireState().journal;
508
+ }
509
+ /** The durable note store beside the journal. */
510
+ get notes() {
511
+ return this.requireState().notes;
512
+ }
513
+ /** The settings this host runs arbor with. */
514
+ get settings() {
515
+ return this.requireState().settings;
516
+ }
517
+ /**
518
+ * Arbor's own workspace over the shared folder: the implementation every
519
+ * window's files go through, and the one a read-only surface asks for the
520
+ * chain's checkouts, keeps and open work.
521
+ */
522
+ get workspace() {
523
+ return this.requireState().workspace;
524
+ }
525
+ /** The environment every window reads at `/env`, DSH's items mounted beside arbor's. */
526
+ get environment() {
527
+ return this.requireState().environment;
528
+ }
529
+ /** The arbor shared folder: the projects' home and ovnfs's source. */
530
+ get sharedFolder() {
531
+ return this.requireState().sharedFolder;
532
+ }
533
+ /** Which workspace backend arbor settled on: `ovnfs` or `dir`. */
534
+ get workspaceBackend() {
535
+ return this.requireState().workspaceBackend;
536
+ }
537
+ /** The DSH project bridge over the shared folder. */
538
+ get projects() {
539
+ return this.requireState().projects;
540
+ }
541
+ /**
542
+ * The DSH session whose work this window runs, or undefined while nothing
543
+ * attributes it. A window a restart continued before its session came back
544
+ * answers undefined until the session resumes and its next turn opens the
545
+ * window's own chain entry; the pointer section then reads no pointers rather
546
+ * than another session's.
547
+ */
548
+ sessionOfWindow(window) {
549
+ return this.windowSessions.get(window);
550
+ }
551
+ /**
552
+ * The project of the shared folder the named window's session works in, or
553
+ * undefined when it works in none.
554
+ *
555
+ * The project is read from the session's own current directory, which the
556
+ * driver resolves into the shared folder for every arbor session — created
557
+ * under arbor, re-routed onto it, or resumed after a restart — so a session the
558
+ * user opened on a project and a session that picked arbor afterwards answer
559
+ * the same way.
560
+ */
561
+ async projectOfWindow(window) {
562
+ if (window === undefined)
563
+ return undefined;
564
+ const sessionId = this.windowSessions.get(window);
565
+ if (sessionId === undefined)
566
+ return undefined;
567
+ return this.requireState().projects.projectOf(await this.directoryOfSession(sessionId));
568
+ }
569
+ /**
570
+ * The directory one session works in, the way DSH answers it.
571
+ *
572
+ * The `workingDirectory` projection is DSH's own answer for a session's
573
+ * current directory — the cwd display, the session listing and the skill
574
+ * catalog all read it — while the session header carries the directory the
575
+ * session was created with and never moves. A session whose directory changed
576
+ * therefore names the new one, which is what the project line has to say: the
577
+ * alternative is a prompt that names a project the UI no longer shows.
578
+ *
579
+ * A window recovered before its session resumed has no live agent to read —
580
+ * the host continues it at boot, long before the user opens the session again
581
+ * — and its prefix is frozen then. The stored session header is the durable
582
+ * answer for that moment, the same one the driver resolves a resume from, so
583
+ * the recovered window opens naming the project it was opened on. It is read
584
+ * once per session and remembered: a session's stored header never moves, and
585
+ * the live answer takes over the moment the session is back.
586
+ */
587
+ /**
588
+ * The directory one session works in, the way the `<workspace>` section reads
589
+ * it: DSH's `workingDirectory` projection when the session is live, the
590
+ * stored header otherwise. A read-only surface that names the session's
591
+ * project asks this rather than re-deriving it.
592
+ */
593
+ directoryOf(sessionId) {
594
+ return this.directoryOfSession(sessionId);
595
+ }
596
+ async directoryOfSession(sessionId) {
597
+ const session = this.ctx.agents.get(sessionId)?.session;
598
+ if (session !== undefined) {
599
+ const projected = this.ctx.sessionProjections
600
+ .snapshot(session, ['workingDirectory']).values.workingDirectory;
601
+ return projected ?? session.header.cwd;
602
+ }
603
+ if (!this.storedCwds.has(sessionId)) {
604
+ this.storedCwds.set(sessionId, await this.storedCwdOf(sessionId));
605
+ }
606
+ return this.storedCwds.get(sessionId);
607
+ }
608
+ /** The cwd a persisted session was created with, or undefined without a store. */
609
+ async storedCwdOf(sessionId) {
610
+ const persistence = this.ctx.get('sessionPersistence');
611
+ if (persistence === undefined)
612
+ return undefined;
613
+ try {
614
+ const handle = await persistence.open(sessionId, 'read');
615
+ try {
616
+ return handle.header.cwd;
617
+ }
618
+ finally {
619
+ await handle.close();
620
+ }
621
+ }
622
+ catch {
623
+ return undefined;
624
+ }
625
+ }
626
+ /**
627
+ * A revision that moves whenever anything the `<workspace>` section names
628
+ * moves: any session's pointers, or any session's project. The host asks for it
629
+ * once per step, so a window whose session picks another project before its
630
+ * first turn is handed a delta of only the project line, and a session that
631
+ * gains or loses a pointer is handed a delta of only that line.
632
+ */
633
+ async workspaceRevision() {
634
+ const projects = this.requireState().projects;
635
+ const pointers = await projects.pointerRevision();
636
+ const parts = [];
637
+ for (const sessionId of this.sessionProjects()) {
638
+ const project = projects.projectOf(await this.directoryOfSession(sessionId));
639
+ parts.push(`${sessionId}=${project?.path ?? ''}`);
640
+ }
641
+ return `${pointers}#${parts.sort().join(';')}`;
642
+ }
643
+ /**
644
+ * Every session the `<workspace>` section answers for: the live ones, plus the
645
+ * ones a recovered window still names — a window the host continued at boot
646
+ * belongs to a session whose agent is not back yet, and its project is part of
647
+ * what the section says.
648
+ */
649
+ sessionProjects() {
650
+ const ids = new Set(this.ctx.agents.list().map(agent => agent.session.id));
651
+ for (const sessionId of this.windowSessions.values())
652
+ ids.add(sessionId);
653
+ return [...ids];
654
+ }
655
+ /**
656
+ * Where one arbor session works: a project folder inside the shared folder.
657
+ *
658
+ * The driver calls this for an arbor session's requested cwd and sets the
659
+ * session's cwd to the answer, so the DSH session, its cwd display and the
660
+ * project picker all agree on the shared folder. A directory outside the
661
+ * shared folder is not copied; it joins the shared folder only through
662
+ * {@link adoptArborProject}.
663
+ */
664
+ resolveArborCwd(requested) {
665
+ return this.requireState().projects.resolveProject(requested);
666
+ }
667
+ /**
668
+ * Whether the user added this directory through DSH's own add-workspace
669
+ * action, outside the shared folder.
670
+ *
671
+ * This is what separates a directory the user means arbor to name from a cwd a
672
+ * session merely inherited: the add-workspace action is the one path in DSH
673
+ * that registers a directory of the user's choosing, so it is the only one
674
+ * that may turn that directory into a pointer. The driver asks before it
675
+ * records one.
676
+ */
677
+ isAddedArborDirectory(path) {
678
+ return this.requireState().projects.isAddedDirectory(path);
679
+ }
680
+ /**
681
+ * Record an existing directory as an external pointer for one session: no
682
+ * copy, no link, no adoption, never a registered project. The owner's decision
683
+ * for an existing DSH project with its own path — the session works in arbor's
684
+ * own directory (the shared folder) and the user's original directory is named
685
+ * in arbor's prompt as a pointer, so the model reaches it directly. The driver
686
+ * calls it for a session that became an arbor session while naming an added
687
+ * directory; the model calls it for one it was asked about.
688
+ */
689
+ pointerArborProject(path, sessionId) {
690
+ return this.requireState().projects.addPointer(sessionId, path);
691
+ }
692
+ /**
693
+ * Make one arbor session a member of the workspace that owns the directory it
694
+ * works in, so DSH's own hero composer has a workspace to name and stays live.
695
+ *
696
+ * The driver calls this for every arbor session it creates or re-routes, with
697
+ * the cwd it resolved for it: an arbor session works in arbor's own directory,
698
+ * and DSH filters a workspace's member list by the session's stored cwd, so
699
+ * the session has to be a member of the workspace whose path is that
700
+ * directory. See {@link DshProjectBridge.attachWorkspace} for why the composer
701
+ * is inert without it.
702
+ */
703
+ attachArborWorkspace(sessionId, cwd) {
704
+ return this.requireState().projects.attachWorkspace(sessionId, cwd);
705
+ }
706
+ /** Drop a recorded external pointer by name for one session. */
707
+ removeArborPointer(name, sessionId) {
708
+ this.requireState().projects.removePointer(sessionId, name);
709
+ }
710
+ /**
711
+ * Every external pointer one session recorded whose directory still exists,
712
+ * true at read. This is what that session's `<workspace>` prompt section
713
+ * names, and only that session's.
714
+ */
715
+ listArborPointers(sessionId) {
716
+ return this.requireState().projects.listPointers(sessionId);
717
+ }
718
+ /** Every project registered from the shared folder, as DSH lists them. */
719
+ listArborProjects() {
720
+ return this.requireState().projects.list();
721
+ }
722
+ /** The backend every window of this process runs through. */
723
+ get backend() {
724
+ return this.requireState().backend;
725
+ }
726
+ /** Write the sends a step delivered, after all its tool results are on the log. */
727
+ flushSends() {
728
+ this.requireState().sendExecutor.flush();
729
+ }
730
+ /** The directory the journal and the note store live in. */
731
+ get dataRoot() {
732
+ return this.requireState().root;
733
+ }
734
+ /**
735
+ * Every window that serves this session, oldest first.
736
+ *
737
+ * A window carries the session's target in its own `window_start`, and a
738
+ * continuation carries its predecessor instead; a chain therefore starts at a
739
+ * target-carrying window and every continuation leads back to one. A session
740
+ * can have several such roots — one per turn after an idle window ended — and
741
+ * each root's continuations belong to the same session. The journal's start
742
+ * order is what orders them all, and it is what the relay reads the chain's
743
+ * tool calls back out of on resume.
744
+ */
745
+ async windowChainOf(sessionId) {
746
+ // A window carries the label its task named, and arbor carries that label
747
+ // onto the window's continuations and the journal's record of both. The
748
+ // label is the embedding's own session id, so it names this session's
749
+ // windows directly - every root a turn after an idle end opened, and every
750
+ // continuation of each - without walking targets or a process map.
751
+ const labeled = [];
752
+ const { journal } = this.requireState();
753
+ for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
754
+ const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
755
+ for (const event of page.items) {
756
+ if (event.window === null)
757
+ continue;
758
+ if (readLabel(event) === sessionId)
759
+ labeled.push({ window: event.window, seq: event.seq });
760
+ }
761
+ if (page.items.length < MAX_PAGE_LIMIT)
762
+ break;
763
+ }
764
+ if (labeled.length > 0) {
765
+ return labeled.sort((left, right) => left.seq - right.seq).map(entry => entry.window);
766
+ }
767
+ // A journal written before arbor carried labels has no direct answer; the
768
+ // target walk it used is kept so an upgraded DSH_HOME stays readable.
769
+ return this.windowChainByTarget(sessionId);
770
+ }
771
+ /**
772
+ * The pre-label chain walk: the session's target names its first windows and
773
+ * each continuation names its predecessor. Only a journal with no labels
774
+ * reaches this; a current one answers by label.
775
+ */
776
+ async windowChainByTarget(sessionId) {
777
+ const target = sessionTarget(sessionId);
778
+ const { journal } = this.requireState();
779
+ /** When each `window_start` was appended, in the journal's own order. */
780
+ const startedAt = new Map();
781
+ /** The window each `window_start` names as its predecessor. */
782
+ const previousOf = new Map();
783
+ /** The windows whose own start carries this session's target. */
784
+ const roots = new Set();
785
+ for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
786
+ const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
787
+ for (const event of page.items) {
788
+ if (event.window === null)
789
+ continue;
790
+ startedAt.set(event.window, event.seq);
791
+ if (event.targets.includes(target))
792
+ roots.add(event.window);
793
+ const previous = continuedWindow(event.body);
794
+ if (previous !== undefined)
795
+ previousOf.set(event.window, previous);
796
+ }
797
+ if (page.items.length < MAX_PAGE_LIMIT)
798
+ break;
799
+ }
800
+ const rootOf = (window) => {
801
+ const seen = new Set();
802
+ let current = window;
803
+ while (current !== undefined && !seen.has(current)) {
804
+ if (roots.has(current))
805
+ return current;
806
+ seen.add(current);
807
+ current = previousOf.get(current);
808
+ }
809
+ return undefined;
810
+ };
811
+ return [...startedAt.keys()]
812
+ .filter(window => rootOf(window) !== undefined)
813
+ .sort((left, right) => (startedAt.get(left) ?? 0) - (startedAt.get(right) ?? 0));
814
+ }
815
+ /**
816
+ * Rebuild which session every recorded window serves, from the journal.
817
+ *
818
+ * The two hooks attribute a window the moment it opens — `windowStart` from
819
+ * the task's targets, `windowContinue` from the window it continues — and that
820
+ * map is process memory: it died with the process that wrote it. The journal
821
+ * is the durable record of the same fact, because every `window_start` carries
822
+ * the session's target and a continuation names its predecessor in its body.
823
+ * So the attribution is rebuilt here, before the host starts, so that a window
824
+ * the recovery continues opens with a prefix that still names its session.
825
+ *
826
+ * A window no chain leads back to a target — one dispatched from outside a DSH
827
+ * session — is left unattributed, which is the answer the hooks give it too.
828
+ */
829
+ async rebuildWindowSessions() {
830
+ const { journal } = this.requireState();
831
+ const previous = new Map();
832
+ const sessions = new Map();
833
+ for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
834
+ const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
835
+ for (const event of page.items) {
836
+ if (event.window === null)
837
+ continue;
838
+ const label = readLabel(event);
839
+ if (label !== '') {
840
+ // The embedding's own label, which arbor carried onto the window's
841
+ // start and its continuations: no target matching is needed.
842
+ sessions.set(event.window, label);
843
+ }
844
+ else {
845
+ // A journal written before labels carries the target instead.
846
+ for (const target of event.targets) {
847
+ const sessionId = sessionOfTarget(target);
848
+ if (sessionId !== undefined)
849
+ sessions.set(event.window, sessionId);
850
+ }
851
+ }
852
+ const before = continuedWindow(event.body);
853
+ if (before !== undefined)
854
+ previous.set(event.window, before);
855
+ }
856
+ if (page.items.length < MAX_PAGE_LIMIT)
857
+ break;
858
+ }
859
+ // A continuation of a window whose start named no label inherits its
860
+ // predecessor's attribution, however many continuations deep it runs.
861
+ for (const window of previous.keys()) {
862
+ if (sessions.has(window))
863
+ continue;
864
+ let current = previous.get(window);
865
+ const seen = new Set([window]);
866
+ while (current !== undefined && !seen.has(current)) {
867
+ seen.add(current);
868
+ const found = sessions.get(current);
869
+ if (found !== undefined) {
870
+ sessions.set(window, found);
871
+ break;
872
+ }
873
+ current = previous.get(current);
874
+ }
875
+ }
876
+ for (const [window, sessionId] of sessions)
877
+ this.windowSessions.set(window, sessionId);
878
+ }
879
+ /**
880
+ * The window this session's work is running in, when it has a live one.
881
+ *
882
+ * The journal is the record of which window belongs to which session: every
883
+ * `window_start` carries the label its task named, arbor carries that label
884
+ * onto the window's continuations, and a window a restart stopped is continued
885
+ * under a new ref with the same label. So the newest labeled start names the
886
+ * session's window, and it is the live one unless the chain has ended.
887
+ */
888
+ async windowFor(sessionId) {
889
+ const { host } = this.requireState();
890
+ const newest = (await this.windowChainOf(sessionId)).at(-1);
891
+ if (newest === undefined)
892
+ return undefined;
893
+ return host.live().some(record => record.window === newest && record.state !== 'ended')
894
+ ? newest
895
+ : undefined;
896
+ }
897
+ }
898
+ export default ArborHostService;