@devframes/hub 0.9.8 → 0.9.10

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.
@@ -1,36 +1,55 @@
1
1
  import { r as defineHubRpcFunction } from "./define-Ceekw2EO.mjs";
2
- import { n as DEFAULT_STATE_USER_SETTINGS, o as HUB_EVENTS } from "./constants-D1zicoJ4.mjs";
3
- import { i as isBareModuleSpecifier, t as buildRemoteConnectionUrl } from "./remote-url-Bgc7gtsP.mjs";
2
+ import { n as DEFAULT_STATE_USER_SETTINGS, o as HUB_EVENTS } from "./constants-Da23JxCZ.mjs";
3
+ import { i as isBareModuleSpecifier, t as buildRemoteConnectionUrl } from "./remote-url-UClKLQ5_.mjs";
4
4
  import { joinURL, withTrailingSlash } from "ufo";
5
5
  import { createEventEmitter } from "devframe/utils/events";
6
- import { createHostContext, createStorage } from "devframe/node";
7
- import { getInternalContext, resolveBasePath } from "devframe/node/hub-internals";
8
- import { debounce } from "perfect-debounce";
6
+ import { existsSync } from "node:fs";
7
+ import process from "node:process";
9
8
  import { coerceAgentPositionalArgs, resolveClientAssets } from "devframe/internal";
10
- import { defineDiagnostics } from "devframe/utils/nostics";
11
9
  import { basename, dirname, isAbsolute, join, resolve } from "pathe";
10
+ import { defineDiagnostics } from "devframe/utils/nostics";
11
+ import { getInternalContext, resolveBasePath } from "devframe/node/hub-internals";
12
+ import { createHostContext, createStorage } from "devframe/node";
13
+ import { debounce } from "perfect-debounce";
12
14
  import { nanoid } from "devframe/utils/nanoid";
13
- import process from "node:process";
14
- import { existsSync } from "node:fs";
15
15
  //#region src/node/diagnostics.ts
16
+ /**
17
+ * Hub-side diagnostics for docks, terminals, messages, and commands.
18
+ * Shares the `DF` prefix with devframe core; the hub reserves the
19
+ * `DF8xxx` range so the unified surface stays collision-free.
20
+ * Sub-ranges:
21
+ * DF8000-DF8099: hub context / lifecycle
22
+ * DF8100-DF8199: docks
23
+ * DF8200-DF8299: terminals
24
+ * DF8300-DF8399: messages
25
+ * DF8400-DF8499: commands
26
+ */
16
27
  const diagnostics = defineDiagnostics({
17
28
  docsBase: "https://devfra.me/errors",
18
29
  codes: {
19
30
  DF8000: {
20
- why: (p) => `Devframe id "${p.id}" collides with a reserved hub path — it cannot be mounted directly under the hub base.`,
31
+ why: (p) => `Devframe id "${p.id}" collides with a reserved hub path, so it cannot be mounted directly under the hub base.`,
21
32
  fix: "The filenames directly under the hub base (`__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, `__mcp`, `embedded.js`) are reserved for the hub protocol. Rename the devframe id, or override its mount with a non-colliding `basePath`."
22
33
  },
23
34
  DF8002: {
24
- why: "initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.",
25
- fix: "Pass `devframes` to let the instance create the hub context and mount each frame itself, or pass a pre-built `context` (your host already mounted the frames) — never both."
35
+ why: "initHub received both `devframes` and `context`; the two assembly modes are mutually exclusive.",
36
+ fix: "Pass `devframes` to let the instance create the hub context and mount each frame itself, or pass a pre-built `context` (your host already mounted the frames), but never both."
26
37
  },
27
38
  DF8003: {
28
39
  why: "connectionMeta() was called before initHub finished initializing.",
29
- fix: "Await `instance.ready` (or any request through `instance.handler`) before reading `connectionMeta()` — the WebSocket binding it describes is only known once initialization completes."
40
+ fix: "Await `instance.ready` (or any request through `instance.handler`) before reading `connectionMeta()`, since the WebSocket binding it describes is only known once initialization completes."
30
41
  },
31
42
  DF8004: {
32
- why: (p) => `Devframe id "${p.id}" is not a mountable URL segment — the hub mounts each frame at \`<base><id>/\`.`,
33
- fix: "Ids become route segments, so they may only contain letters, digits, `_`, `-`, and `.` — `:` and `*` are route-pattern markers to the underlying router, and `/` would escape the segment. Set a route-safe `id` on the definition (e.g. `my_plugin` instead of `my:plugin`)."
43
+ why: (p) => `Devframe id "${p.id}" is not a mountable URL segment, and the hub mounts each frame at \`<base><id>/\`.`,
44
+ fix: "Ids become route segments, so they may only contain letters, digits, `_`, `-`, and `.`; `:` and `*` are route-pattern markers to the underlying router, and `/` would escape the segment. Set a route-safe `id` on the definition (e.g. `my_plugin` instead of `my:plugin`)."
45
+ },
46
+ DF8005: {
47
+ why: (p) => `Devframe "${p.id}" requests an MCP route, but the hub's aggregate MCP is off (\`mcp: false\`), so its tools are not exposed over MCP.`,
48
+ fix: "A hub exposes one aggregate MCP endpoint over every mounted devframe, so per-devframe `mcp` settings are ignored. Drop `mcp: false` from `initHub` (the `'auto'` default mounts the aggregate route once agent tools exist) to surface this devframe's tools, or drop `mcp` from the devframe to silence this warning."
49
+ },
50
+ DF8006: {
51
+ why: (p) => `A static hub build can only write mounts under its own base: "${p.urlBase}" escapes "${p.base}".`,
52
+ fix: "buildHub maps each mount base to a directory under its `outDir`, so every mount must live under the hub base. Drop the `basePath` override (or the `ctx.install` base) that points outside it, or move the hub `base` up so it contains the mount."
34
53
  },
35
54
  DF8100: {
36
55
  why: (p) => `Dock with id "${p.id}" is already registered`,
@@ -57,12 +76,12 @@ const diagnostics = defineDiagnostics({
57
76
  fix: "Each devframe is deduplicated by id. Set `duplicationStrategy: \"duplicate\"` on the definition to let instances coexist, `\"silent\"` to drop duplicates quietly, or `\"throw\"` to surface them as errors."
58
77
  },
59
78
  DF8106: {
60
- why: (p) => `The host cannot serve the RPC connection meta for devframe "${p.name}" (id "${p.id}") at "${p.base}" — its \`DevframeHost\` does not implement \`mountConnectionMeta\`.`,
79
+ why: (p) => `The host cannot serve the RPC connection meta for devframe "${p.name}" (id "${p.id}") at "${p.base}"; its \`DevframeHost\` does not implement \`mountConnectionMeta\`.`,
61
80
  fix: "Implement `mountConnectionMeta(base)` on your DevframeHost so it serves `__connection.json` at each mounted base. Without it, the devframe SPA connects only when it shares an origin with the hub UI (same-origin window inheritance); cross-origin, sandboxed, or directly-opened iframes stay disconnected. Static-snapshot hosts that bake the meta into the served files can implement it as a no-op to acknowledge this intentionally."
62
81
  },
63
82
  DF8107: {
64
83
  why: (p) => `Dock activation requested for unknown dock id "${p.id}"`,
65
- fix: "Pass a `dockId` that matches a registered dock entry. The activation is still broadcast, but no hub UI provider will switch to it. Ids are case-sensitive — check for typos, and ensure the target dock is registered before activating it."
84
+ fix: "Pass a `dockId` that matches a registered dock entry. The activation is still broadcast, but no hub UI provider will switch to it. Ids are case-sensitive, so check for typos, and ensure the target dock is registered before activating it."
66
85
  },
67
86
  DF8108: {
68
87
  why: (p) => `A renderer module is already registered for dock type "${p.type}"`,
@@ -73,11 +92,11 @@ const diagnostics = defineDiagnostics({
73
92
  fix: "Point the registration's `file` at the prebuilt browser ES module (build the renderer package first, or check the path). Registration helpers like `jsonRenderUiRenderer()` resolve the path for you."
74
93
  },
75
94
  DF8110: {
76
- why: (p) => `Dock type "${p.type}" is not a servable renderer-module name — the hub serves each module at \`<base>__renderers/<type>.mjs\``,
95
+ why: (p) => `Dock type "${p.type}" is not a servable renderer-module name; the hub serves each module at \`<base>__renderers/<type>.mjs\``,
77
96
  fix: "Renderer types become URL segments, so they may only contain letters, digits, `_`, `-`, and `.`. Use a route-safe dock type (e.g. `json-render`)."
78
97
  },
79
98
  DF8111: {
80
- why: (p) => `Dock "${p.id}" declares the bare-specifier client script "${p.specifier}", but this host advertises no client-module resolution — the browser cannot resolve a bare npm specifier natively, so the script will fail to load.`,
99
+ why: (p) => `Dock "${p.id}" declares the bare-specifier client script "${p.specifier}", but this host advertises no client-module resolution, and the browser cannot resolve a bare npm specifier natively, so the script will fail to load.`,
81
100
  fix: "Run under a host framework that declares `initHub({ clientModuleResolution })` (e.g. Vite's `'/@id/{specifier}'`), ship the script as a self-contained bundle served by URL, or resolve it in the hub UI provider via `createDevframeClientRuntime({ resolveClientModule })` (then disregard this warning)."
82
101
  },
83
102
  DF8200: { why: (p) => `Terminal session with id "${p.id}" already registered` },
@@ -89,14 +108,14 @@ const diagnostics = defineDiagnostics({
89
108
  DF8203: { why: (p) => `Failed to spawn PTY session for "${p.command}": ${p.reason}` },
90
109
  DF8204: {
91
110
  why: (p) => `Terminal session "${p.id}" cannot be controlled (no lifecycle handle)`,
92
- fix: "Spawn it via ctx.terminals.startChildProcess() or startPtySession() — sessions added with a bare register() expose no terminate/restart handle."
111
+ fix: "Spawn it via ctx.terminals.startChildProcess() or startPtySession(); sessions added with a bare register() expose no terminate/restart handle."
93
112
  },
94
113
  DF8205: {
95
114
  why: (p) => `Terminal session "${p.id}" is not restartable`,
96
115
  fix: "It was registered with `restartable: false`; restart it through its owner's controls, or spawn it with `restartable: true` (the default) to allow in-place restarts."
97
116
  },
98
117
  DF8206: {
99
- why: (p) => `Terminal session "${p.id}" cannot be restarted — its output stream is already closed`,
118
+ why: (p) => `Terminal session "${p.id}" cannot be restarted because its output stream is already closed`,
100
119
  fix: "The session already exited (or was terminated) and its stream is spent. Drop it with `ctx.terminals.remove(session)`, then spawn a replacement via `ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` with a fresh id."
101
120
  },
102
121
  DF8400: { why: (p) => `Command "${p.id}" is already registered` },
@@ -113,6 +132,134 @@ const diagnostics = defineDiagnostics({
113
132
  }
114
133
  });
115
134
  //#endregion
135
+ //#region src/node/install-devframe.ts
136
+ /**
137
+ * Find the next free dock id derived from `baseId`. Returns `baseId`
138
+ * when it is unused, otherwise appends `-2`, `-3`, … until a free slot
139
+ * is found. Used by the `'duplicate'` strategy so co-existing instances
140
+ * never collide in the dock registry.
141
+ */
142
+ function nextAvailableDockId(views, baseId) {
143
+ if (!views.has(baseId)) return baseId;
144
+ let n = 2;
145
+ while (views.has(`${baseId}-${n}`)) n++;
146
+ return `${baseId}-${n}`;
147
+ }
148
+ /**
149
+ * When a dock's `clientScript.importFrom` is an absolute filesystem path, serve
150
+ * its directory under `<base>__page-script/` and rewrite `importFrom` to that
151
+ * URL. A URL or bare specifier (not existing on disk) passes through untouched.
152
+ */
153
+ async function resolvePageScriptClientScript(ctx, clientScript, base) {
154
+ if (!clientScript?.importFrom) return clientScript;
155
+ const { importFrom } = clientScript;
156
+ if (!isAbsolute(importFrom) || !existsSync(importFrom)) return clientScript;
157
+ const scriptBase = withTrailingSlash(joinURL(base, "__page-script"));
158
+ await ctx.host.mountStatic(scriptBase, dirname(importFrom));
159
+ return {
160
+ ...clientScript,
161
+ importFrom: joinURL(scriptBase, basename(importFrom))
162
+ };
163
+ }
164
+ /**
165
+ * Framework-neutral primitive backing {@link DevframeHubContext.install} -
166
+ * installs a {@link DevframeDefinition} as a dock inside a hub-aware context:
167
+ * serves the devframe's SPA at the resolved base path, synthesizes an iframe
168
+ * dock entry from the definition's metadata, and runs the definition's
169
+ * `setup(ctx)`. Reach for it through `ctx.install(devframe)` rather than
170
+ * calling it directly.
171
+ *
172
+ * Framework kits wrap `ctx.install` with their own plugin/middleware
173
+ * machinery, e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
174
+ * returns a Vite `Plugin` whose `devtools.setup` ultimately delegates here.
175
+ */
176
+ /**
177
+ * Phase one of an install: run the duplication guard, serve the SPA + meta,
178
+ * register the iframe dock, and queue the definition's declarative wire
179
+ * services, everything up to (but not including) `setup(ctx)`. Returns a
180
+ * deferred setup thunk, or `null` when the devframe was deduplicated.
181
+ *
182
+ * The hub's initial batch uses this to collect every devframe's services
183
+ * across the whole hub, `ready()` them once, and only then run the setups,
184
+ * so services are ready before any setup, and a plugin can consume a service
185
+ * another plugin declared regardless of mount order.
186
+ */
187
+ /**
188
+ * Serve a devframe's SPA (and the hub's connection meta) under `base`, if the
189
+ * definition ships client assets.
190
+ */
191
+ async function serveDevframeAssets(ctx, d, id, base) {
192
+ const clientAssets = resolveClientAssets(d);
193
+ if (!clientAssets) return;
194
+ if (ctx.host.mountConnectionMeta) await ctx.host.mountConnectionMeta(base);
195
+ else diagnostics.DF8106({
196
+ id,
197
+ name: d.name,
198
+ base
199
+ });
200
+ ctx.views.hostStatic(base, typeof clientAssets === "string" ? resolve(clientAssets) : clientAssets, d.importMetaUrl);
201
+ }
202
+ /**
203
+ * Whether a devframe stays out of a static hub build: `capabilities.build:
204
+ * false` declares its value inherently live (a terminal, a process proxy),
205
+ * so `buildHub` never mounts it, registers its dock, or bakes its RPCs.
206
+ */
207
+ function skippedInStaticBuild(ctx, d) {
208
+ return ctx.mode === "build" && d.capabilities?.build === false;
209
+ }
210
+ async function prepareDevframe(ctx, d, options = {}) {
211
+ if (skippedInStaticBuild(ctx, d)) return null;
212
+ const strategy = d.duplicationStrategy ?? "warn";
213
+ const isDuplicate = ctx.docks.views.has(d.id);
214
+ if (isDuplicate && strategy !== "duplicate") {
215
+ if (strategy === "throw") throw diagnostics.DF8105({
216
+ id: d.id,
217
+ name: d.name
218
+ });
219
+ if (strategy === "warn") diagnostics.DF8105({
220
+ id: d.id,
221
+ name: d.name
222
+ });
223
+ return null;
224
+ }
225
+ const id = isDuplicate ? nextAvailableDockId(ctx.docks.views, d.id) : d.id;
226
+ const base = options.base ?? (id === d.id ? resolveBasePath(d, "hosted") : resolveBasePath({
227
+ ...d,
228
+ id,
229
+ basePath: void 0
230
+ }, "hosted"));
231
+ const dockDefaults = {
232
+ ...d.dock,
233
+ ...options.dock
234
+ };
235
+ const clientScript = await resolvePageScriptClientScript(ctx, dockDefaults.clientScript, base);
236
+ if (clientScript) dockDefaults.clientScript = clientScript;
237
+ await serveDevframeAssets(ctx, d, id, base);
238
+ ctx.docks.register({
239
+ id,
240
+ title: d.name,
241
+ icon: d.icon,
242
+ ...dockDefaults,
243
+ type: "iframe",
244
+ url: base
245
+ });
246
+ for (const input of d.services ?? []) ctx.services.install(input, { resolveFrom: d.importMetaUrl });
247
+ return () => Promise.resolve(d.setup(ctx));
248
+ }
249
+ /**
250
+ * Install a {@link DevframeDefinition} into a hub in one call: serve its SPA,
251
+ * register its dock, ready its services, and run `setup(ctx)`. The imperative
252
+ * counterpart to the hub's declarative `devframes` list (which batches the
253
+ * phases via {@link prepareDevframe}); use it from `configure(ctx)` or
254
+ * wherever you hold the context to plug in an extra devframe after startup.
255
+ */
256
+ async function installDevframe(ctx, d, options = {}) {
257
+ const run = await prepareDevframe(ctx, d, options);
258
+ if (!run) return;
259
+ await ctx.services.ready();
260
+ await run();
261
+ }
262
+ //#endregion
116
263
  //#region src/node/host-commands.ts
117
264
  function findChildCommand(command, id) {
118
265
  for (const child of command.children ?? []) {
@@ -145,7 +292,7 @@ var DevframeCommandsHost = class {
145
292
  events = createEventEmitter();
146
293
  /**
147
294
  * Lazy agent projection: `ctx.agent` queries this provider at list/invoke
148
- * time, deriving tools from {@link commands} on demand — the commands map
295
+ * time, deriving tools from {@link commands} on demand, so the commands map
149
296
  * stays the single source of truth, nothing is mirrored or kept in sync.
150
297
  */
151
298
  agentProvider;
@@ -223,7 +370,7 @@ var DevframeCommandsHost = class {
223
370
  * agent-flagged, handler-bearing command (children included) becomes a
224
371
  * callable tool. Queried lazily by the provider registered in the
225
372
  * constructor. `when` clauses evaluate client-side only and are not
226
- * enforced here — opting in a `when`-gated command is a deliberate author
373
+ * enforced here; opting in a `when`-gated command is a deliberate author
227
374
  * decision (documented on `DevframeCommandAgentOptions`).
228
375
  */
229
376
  collectAgentTools() {
@@ -236,7 +383,12 @@ var DevframeCommandsHost = class {
236
383
  description: agent.description,
237
384
  safety: agent.safety ?? "action",
238
385
  tags: agent.tags,
386
+ /** The agent host derives the tool's JSON-Schema input from these. */
239
387
  args: agent.args,
388
+ /**
389
+ * A command handler's positional parameters come solely from its
390
+ * declared `agent.args` schemas; undeclared payload is dropped.
391
+ */
240
392
  handler: async (args) => this.execute(command.id, ...coerceAgentPositionalArgs(args, agent.args, "drop"))
241
393
  });
242
394
  for (const child of command.children ?? []) walk(child);
@@ -266,6 +418,7 @@ var DevframeDocksHost = class {
266
418
  }
267
419
  async init() {
268
420
  this.userSettings = await this.context.rpc.sharedState.get(HUB_EVENTS.sharedState.userSettings, { sharedState: createStorage({
421
+ /** Personal dock layout/preferences: per-checkout private state. */
269
422
  filepath: join(this.context.host.getStorageDir("project"), "settings.json"),
270
423
  initialValue: DEFAULT_STATE_USER_SETTINGS()
271
424
  }) });
@@ -326,7 +479,7 @@ var DevframeDocksHost = class {
326
479
  });
327
480
  }
328
481
  /**
329
- * Warn (don't throw — a client-runtime `resolveClientModule` override may still cover
482
+ * Warn (don't throw, since a client-runtime `resolveClientModule` override may still cover
330
483
  * it) when a dock declares a **bare-specifier** client script on a host
331
484
  * that advertises no `staticConfig.dock.clientModuleResolution`: the
332
485
  * browser cannot resolve a bare npm specifier natively, so the script is
@@ -380,7 +533,7 @@ var DevframeMessagesHost = class {
380
533
  _clock = 0;
381
534
  /**
382
535
  * The tick of the newest removal record dropped from the capped
383
- * `removals` log — cursors older than this can't get a reliable delta
536
+ * `removals` log; cursors older than this can't get a reliable delta
384
537
  * and fall back to a full snapshot in {@link listSince}.
385
538
  */
386
539
  _removalsTrimmedAt = 0;
@@ -561,7 +714,7 @@ var DevframeTerminalsHost = class {
561
714
  this.context = context;
562
715
  }
563
716
  /**
564
- * Lazily acquire the streaming channel — `context.rpc` isn't assigned
717
+ * Lazily acquire the streaming channel, since `context.rpc` isn't assigned
565
718
  * until after every host is constructed, so we can't grab it in the
566
719
  * constructor.
567
720
  */
@@ -571,6 +724,41 @@ var DevframeTerminalsHost = class {
571
724
  this._channel = this.context.rpc.streaming.create(TERMINAL_STREAM_CHANNEL, { replayWindow: TERMINAL_REPLAY_WINDOW });
572
725
  return this._channel;
573
726
  }
727
+ /**
728
+ * Per-run stream lifecycle shared by the child-process and PTY paths: guards
729
+ * the single-close/error transition and keeps the session's `status` in step
730
+ * with the process lifecycle (a hub-aware client sees `running` →
731
+ * `stopped`/`error` instead of a value frozen at spawn time).
732
+ */
733
+ createStreamLifecycle() {
734
+ const state = { streamClosed: false };
735
+ const markStatus = (next) => {
736
+ const session = state.session;
737
+ if (session.status === next) return;
738
+ session.status = next;
739
+ this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
740
+ };
741
+ const closeStream = () => {
742
+ if (state.streamClosed) return;
743
+ state.streamClosed = true;
744
+ try {
745
+ state.controller?.close();
746
+ } catch {}
747
+ };
748
+ const errorStream = (error) => {
749
+ if (state.streamClosed) return;
750
+ state.streamClosed = true;
751
+ try {
752
+ state.controller?.error(error);
753
+ } catch {}
754
+ };
755
+ return {
756
+ state,
757
+ markStatus,
758
+ closeStream,
759
+ errorStream
760
+ };
761
+ }
574
762
  register(session) {
575
763
  if (this.sessions.has(session.id)) throw diagnostics.DF8200({ id: session.id });
576
764
  this.sessions.set(session.id, session);
@@ -592,6 +780,25 @@ var DevframeTerminalsHost = class {
592
780
  this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
593
781
  this._boundStreams.delete(session.id);
594
782
  }
783
+ /** Drain a session's output stream into its scrollback buffer and streaming sink. */
784
+ async pumpStream(reader, sessionBuffer, sink, isDisposed) {
785
+ try {
786
+ while (!isDisposed()) {
787
+ const result = await reader.read();
788
+ if (isDisposed() || result.done) break;
789
+ sessionBuffer.push(result.value);
790
+ if (sessionBuffer.length > TERMINAL_BUFFER_LIMIT) sessionBuffer.splice(0, sessionBuffer.length - TERMINAL_BUFFER_LIMIT);
791
+ sink?.write(result.value);
792
+ }
793
+ if (!isDisposed() && sink && !sink.closed) sink.close();
794
+ } catch (error) {
795
+ if (!isDisposed() && sink && !sink.closed) sink.error(error);
796
+ } finally {
797
+ try {
798
+ reader.releaseLock();
799
+ } catch {}
800
+ }
801
+ }
595
802
  bindStream(session) {
596
803
  if (this._boundStreams.has(session.id) && this._boundStreams.get(session.id)?.stream === session.stream) return;
597
804
  this._boundStreams.get(session.id)?.dispose();
@@ -602,26 +809,7 @@ var DevframeTerminalsHost = class {
602
809
  const sink = this.getStreamingChannel()?.start({ id: session.id });
603
810
  const reader = session.stream.getReader();
604
811
  let disposed = false;
605
- (async () => {
606
- try {
607
- while (true) {
608
- if (disposed) break;
609
- const result = await reader.read();
610
- if (disposed) break;
611
- if (result.done) break;
612
- sessionBuffer.push(result.value);
613
- if (sessionBuffer.length > TERMINAL_BUFFER_LIMIT) sessionBuffer.splice(0, sessionBuffer.length - TERMINAL_BUFFER_LIMIT);
614
- sink?.write(result.value);
615
- }
616
- if (!disposed && sink && !sink.closed) sink.close();
617
- } catch (error) {
618
- if (!disposed && sink && !sink.closed) sink.error(error);
619
- } finally {
620
- try {
621
- reader.releaseLock();
622
- } catch {}
623
- }
624
- })();
812
+ this.pumpStream(reader, sessionBuffer, sink, () => disposed);
625
813
  this._boundStreams.set(session.id, {
626
814
  dispose: () => {
627
815
  disposed = true;
@@ -634,34 +822,13 @@ var DevframeTerminalsHost = class {
634
822
  async startChildProcess(executeOptions, terminal) {
635
823
  if (this.sessions.has(terminal.id)) throw diagnostics.DF8200({ id: terminal.id });
636
824
  const { exec } = await import("tinyexec");
637
- let controller;
825
+ const { state, markStatus, closeStream, errorStream } = this.createStreamLifecycle();
638
826
  let cp;
639
827
  let currentResult;
640
828
  let runId = 0;
641
- let streamClosed = false;
642
- let session;
643
- const markStatus = (next) => {
644
- if (session.status === next) return;
645
- session.status = next;
646
- this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
647
- };
648
- const closeStream = () => {
649
- if (streamClosed) return;
650
- streamClosed = true;
651
- try {
652
- controller?.close();
653
- } catch {}
654
- };
655
- const errorStream = (error) => {
656
- if (streamClosed) return;
657
- streamClosed = true;
658
- try {
659
- controller?.error(error);
660
- } catch {}
661
- };
662
829
  const stream = new ReadableStream({
663
830
  start(_controller) {
664
- controller = _controller;
831
+ state.controller = _controller;
665
832
  },
666
833
  cancel() {
667
834
  cp?.kill();
@@ -701,13 +868,13 @@ var DevframeTerminalsHost = class {
701
868
  if (currentRun !== runId) return;
702
869
  const text = chunk.toString();
703
870
  stdoutChunks.push(text);
704
- if (!streamClosed) controller?.enqueue(text);
871
+ if (!state.streamClosed) state.controller?.enqueue(text);
705
872
  });
706
873
  cp.process?.stderr?.on("data", (chunk) => {
707
874
  if (currentRun !== runId) return;
708
875
  const text = chunk.toString();
709
876
  stderrChunks.push(text);
710
- if (!streamClosed) controller?.enqueue(text);
877
+ if (!state.streamClosed) state.controller?.enqueue(text);
711
878
  });
712
879
  cp.process?.once("error", (error) => {
713
880
  if (currentRun !== runId) return;
@@ -739,7 +906,7 @@ var DevframeTerminalsHost = class {
739
906
  }
740
907
  cp = createChildProcess();
741
908
  const restart = async () => {
742
- if (streamClosed) throw diagnostics.DF8206({ id: terminal.id });
909
+ if (state.streamClosed) throw diagnostics.DF8206({ id: terminal.id });
743
910
  cp?.kill();
744
911
  cp = createChildProcess();
745
912
  markStatus("running");
@@ -750,7 +917,7 @@ var DevframeTerminalsHost = class {
750
917
  closeStream();
751
918
  markStatus("stopped");
752
919
  };
753
- session = {
920
+ const session = {
754
921
  ...terminal,
755
922
  status: "running",
756
923
  stream,
@@ -761,6 +928,7 @@ var DevframeTerminalsHost = class {
761
928
  terminate,
762
929
  restart
763
930
  };
931
+ state.session = session;
764
932
  this.register(session);
765
933
  return Promise.resolve(session);
766
934
  }
@@ -769,35 +937,14 @@ var DevframeTerminalsHost = class {
769
937
  const { spawn } = await import("zigpty");
770
938
  const cols = executeOptions.cols ?? 80;
771
939
  const rows = executeOptions.rows ?? 24;
772
- let controller;
940
+ const { state, markStatus, closeStream, errorStream } = this.createStreamLifecycle();
773
941
  let pty;
774
942
  let currentResult;
775
943
  let killCurrentRun;
776
944
  let runId = 0;
777
- let streamClosed = false;
778
- let session;
779
- const markStatus = (next) => {
780
- if (session.status === next) return;
781
- session.status = next;
782
- this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
783
- };
784
- const closeStream = () => {
785
- if (streamClosed) return;
786
- streamClosed = true;
787
- try {
788
- controller?.close();
789
- } catch {}
790
- };
791
- const errorStream = (error) => {
792
- if (streamClosed) return;
793
- streamClosed = true;
794
- try {
795
- controller?.error(error);
796
- } catch {}
797
- };
798
945
  const stream = new ReadableStream({
799
946
  start(_controller) {
800
- controller = _controller;
947
+ state.controller = _controller;
801
948
  },
802
949
  cancel() {
803
950
  killCurrentRun?.();
@@ -842,7 +989,7 @@ var DevframeTerminalsHost = class {
842
989
  ptyProcess.onData((data) => {
843
990
  const text = typeof data === "string" ? data : data.toString("utf8");
844
991
  outputChunks.push(text);
845
- if (!streamClosed && currentRun === runId) controller?.enqueue(text);
992
+ if (!state.streamClosed && currentRun === runId) state.controller?.enqueue(text);
846
993
  });
847
994
  ptyProcess.onExit(({ exitCode, signal }) => {
848
995
  settle(exitCode, signal);
@@ -882,7 +1029,7 @@ var DevframeTerminalsHost = class {
882
1029
  reason: error instanceof Error ? error.message : String(error)
883
1030
  });
884
1031
  }
885
- session = {
1032
+ const session = {
886
1033
  ...terminal,
887
1034
  status: "running",
888
1035
  interactive: true,
@@ -915,7 +1062,7 @@ var DevframeTerminalsHost = class {
915
1062
  markStatus("stopped");
916
1063
  },
917
1064
  restart: async () => {
918
- if (streamClosed) throw diagnostics.DF8206({ id: terminal.id });
1065
+ if (state.streamClosed) throw diagnostics.DF8206({ id: terminal.id });
919
1066
  killCurrentRun?.();
920
1067
  killCurrentRun = void 0;
921
1068
  pty = void 0;
@@ -931,125 +1078,12 @@ var DevframeTerminalsHost = class {
931
1078
  markStatus("running");
932
1079
  }
933
1080
  };
1081
+ state.session = session;
934
1082
  this.register(session);
935
1083
  return session;
936
1084
  }
937
1085
  };
938
1086
  //#endregion
939
- //#region src/node/install-devframe.ts
940
- /**
941
- * Find the next free dock id derived from `baseId`. Returns `baseId`
942
- * when it is unused, otherwise appends `-2`, `-3`, … until a free slot
943
- * is found. Used by the `'duplicate'` strategy so co-existing instances
944
- * never collide in the dock registry.
945
- */
946
- function nextAvailableDockId(views, baseId) {
947
- if (!views.has(baseId)) return baseId;
948
- let n = 2;
949
- while (views.has(`${baseId}-${n}`)) n++;
950
- return `${baseId}-${n}`;
951
- }
952
- /**
953
- * When a dock's `clientScript.importFrom` is an absolute filesystem path, serve
954
- * its directory under `<base>__page-script/` and rewrite `importFrom` to that
955
- * URL. A URL or bare specifier (not existing on disk) passes through untouched.
956
- */
957
- async function resolvePageScriptClientScript(ctx, clientScript, base) {
958
- if (!clientScript?.importFrom) return clientScript;
959
- const { importFrom } = clientScript;
960
- if (!isAbsolute(importFrom) || !existsSync(importFrom)) return clientScript;
961
- const scriptBase = withTrailingSlash(joinURL(base, "__page-script"));
962
- await ctx.host.mountStatic(scriptBase, dirname(importFrom));
963
- return {
964
- ...clientScript,
965
- importFrom: joinURL(scriptBase, basename(importFrom))
966
- };
967
- }
968
- /**
969
- * Framework-neutral primitive backing {@link DevframeHubContext.install} —
970
- * installs a {@link DevframeDefinition} as a dock inside a hub-aware context:
971
- * serves the devframe's SPA at the resolved base path, synthesizes an iframe
972
- * dock entry from the definition's metadata, and runs the definition's
973
- * `setup(ctx)`. Reach for it through `ctx.install(devframe)` rather than
974
- * calling it directly.
975
- *
976
- * Framework kits wrap `ctx.install` with their own plugin/middleware
977
- * machinery — e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
978
- * returns a Vite `Plugin` whose `devtools.setup` ultimately delegates here.
979
- */
980
- /**
981
- * Phase one of an install: run the duplication guard, serve the SPA + meta,
982
- * register the iframe dock, and queue the definition's declarative wire
983
- * services — everything up to (but not including) `setup(ctx)`. Returns a
984
- * deferred setup thunk, or `null` when the devframe was deduplicated.
985
- *
986
- * The hub's initial batch uses this to collect every devframe's services
987
- * across the whole hub, `ready()` them once, and only then run the setups —
988
- * so services are ready before any setup, and a plugin can consume a service
989
- * another plugin declared regardless of mount order.
990
- */
991
- async function prepareDevframe(ctx, d, options = {}) {
992
- const strategy = d.duplicationStrategy ?? "warn";
993
- const isDuplicate = ctx.docks.views.has(d.id);
994
- if (isDuplicate && strategy !== "duplicate") {
995
- if (strategy === "throw") throw diagnostics.DF8105({
996
- id: d.id,
997
- name: d.name
998
- });
999
- if (strategy === "warn") diagnostics.DF8105({
1000
- id: d.id,
1001
- name: d.name
1002
- });
1003
- return null;
1004
- }
1005
- const id = isDuplicate ? nextAvailableDockId(ctx.docks.views, d.id) : d.id;
1006
- const base = options.base ?? (id === d.id ? resolveBasePath(d, "hosted") : resolveBasePath({
1007
- ...d,
1008
- id,
1009
- basePath: void 0
1010
- }, "hosted"));
1011
- const dockDefaults = {
1012
- ...d.dock,
1013
- ...options.dock
1014
- };
1015
- const clientScript = await resolvePageScriptClientScript(ctx, dockDefaults.clientScript, base);
1016
- if (clientScript) dockDefaults.clientScript = clientScript;
1017
- const clientAssets = resolveClientAssets(d);
1018
- if (clientAssets) {
1019
- if (ctx.host.mountConnectionMeta) await ctx.host.mountConnectionMeta(base);
1020
- else diagnostics.DF8106({
1021
- id,
1022
- name: d.name,
1023
- base
1024
- });
1025
- const distSource = clientAssets;
1026
- ctx.views.hostStatic(base, typeof distSource === "string" ? resolve(distSource) : distSource, d.importMetaUrl);
1027
- }
1028
- ctx.docks.register({
1029
- id,
1030
- title: d.name,
1031
- icon: d.icon,
1032
- ...dockDefaults,
1033
- type: "iframe",
1034
- url: base
1035
- });
1036
- for (const input of d.services ?? []) ctx.services.install(input, { resolveFrom: d.importMetaUrl });
1037
- return () => Promise.resolve(d.setup(ctx));
1038
- }
1039
- /**
1040
- * Install a {@link DevframeDefinition} into a hub in one call — serve its SPA,
1041
- * register its dock, ready its services, and run `setup(ctx)`. The imperative
1042
- * counterpart to the hub's declarative `devframes` list (which batches the
1043
- * phases via {@link prepareDevframe}); use it from `configure(ctx)` or
1044
- * wherever you hold the context to plug in an extra devframe after startup.
1045
- */
1046
- async function installDevframe(ctx, d, options = {}) {
1047
- const run = await prepareDevframe(ctx, d, options);
1048
- if (!run) return;
1049
- await ctx.services.ready();
1050
- await run();
1051
- }
1052
- //#endregion
1053
1087
  //#region src/node/rpc-builtins.ts
1054
1088
  /**
1055
1089
  * Resolve an interactive (PTY) terminal session by id, or throw. Sessions
@@ -1073,7 +1107,7 @@ function resolveControllableSession(sessions, id) {
1073
1107
  return session;
1074
1108
  }
1075
1109
  /**
1076
- * `hub:commands:execute` — Invoke a registered server command by id. The
1110
+ * `hub:commands:execute`: Invoke a registered server command by id. The
1077
1111
  * arguments after `id` are forwarded to the command's `handler(...)`.
1078
1112
  * Returns whatever the handler returns.
1079
1113
  *
@@ -1088,7 +1122,7 @@ const hubCommandsExecute = defineHubRpcFunction({
1088
1122
  } })
1089
1123
  });
1090
1124
  /**
1091
- * `hub:messages:add` — Add a message from a browser client into the hub's
1125
+ * `hub:messages:add`: Add a message from a browser client into the hub's
1092
1126
  * messages subsystem. Marked `from: 'browser'`. Returns the serializable
1093
1127
  * entry (the mutation handle stays server-side).
1094
1128
  *
@@ -1107,7 +1141,7 @@ const hubMessagesAdd = defineHubRpcFunction({
1107
1141
  })).entry;
1108
1142
  } })
1109
1143
  });
1110
- /** `hub:messages:update` — Patch a message by id; returns the updated entry (or `undefined`). */
1144
+ /** `hub:messages:update`: Patch a message by id; returns the updated entry (or `undefined`). */
1111
1145
  const hubMessagesUpdate = defineHubRpcFunction({
1112
1146
  name: HUB_EVENTS.rpc.messagesUpdate,
1113
1147
  type: "action",
@@ -1116,7 +1150,7 @@ const hubMessagesUpdate = defineHubRpcFunction({
1116
1150
  return context.messages.update(id, patch);
1117
1151
  } })
1118
1152
  });
1119
- /** `hub:messages:remove` — Remove a message by id. */
1153
+ /** `hub:messages:remove`: Remove a message by id. */
1120
1154
  const hubMessagesRemove = defineHubRpcFunction({
1121
1155
  name: HUB_EVENTS.rpc.messagesRemove,
1122
1156
  type: "action",
@@ -1124,7 +1158,7 @@ const hubMessagesRemove = defineHubRpcFunction({
1124
1158
  await context.messages.remove(id);
1125
1159
  } })
1126
1160
  });
1127
- /** `hub:messages:clear` — Remove every message. */
1161
+ /** `hub:messages:clear`: Remove every message. */
1128
1162
  const hubMessagesClear = defineHubRpcFunction({
1129
1163
  name: HUB_EVENTS.rpc.messagesClear,
1130
1164
  type: "action",
@@ -1133,7 +1167,7 @@ const hubMessagesClear = defineHubRpcFunction({
1133
1167
  } })
1134
1168
  });
1135
1169
  /**
1136
- * `hub:terminals:write` — Send input to an interactive PTY session spawned
1170
+ * `hub:terminals:write`: Send input to an interactive PTY session spawned
1137
1171
  * via `ctx.terminals.startPtySession`. Lets a hub-aware terminal UI (e.g. the
1138
1172
  * terminals plugin) drive a session owned by another plugin.
1139
1173
  */
@@ -1144,7 +1178,7 @@ const hubTerminalsWrite = defineHubRpcFunction({
1144
1178
  resolveInteractiveSession(context.terminals.sessions, id).write(data);
1145
1179
  } })
1146
1180
  });
1147
- /** `hub:terminals:resize` — Resize an interactive PTY session by id. */
1181
+ /** `hub:terminals:resize`: Resize an interactive PTY session by id. */
1148
1182
  const hubTerminalsResize = defineHubRpcFunction({
1149
1183
  name: HUB_EVENTS.rpc.terminalsResize,
1150
1184
  type: "action",
@@ -1153,7 +1187,7 @@ const hubTerminalsResize = defineHubRpcFunction({
1153
1187
  } })
1154
1188
  });
1155
1189
  /**
1156
- * `hub:terminals:terminate` — Kill a session's process while keeping the
1190
+ * `hub:terminals:terminate`: Kill a session's process while keeping the
1157
1191
  * session registered (its output/scrollback stays). Works for both read-only
1158
1192
  * child-process and interactive PTY sessions, letting a hub-aware terminal UI
1159
1193
  * force-kill a session owned by another plugin.
@@ -1166,7 +1200,7 @@ const hubTerminalsTerminate = defineHubRpcFunction({
1166
1200
  } })
1167
1201
  });
1168
1202
  /**
1169
- * `hub:terminals:restart` — Re-run a session's command in place. Rejected for
1203
+ * `hub:terminals:restart`: Re-run a session's command in place. Rejected for
1170
1204
  * sessions registered with `restartable: false`, whose lifecycle is owned
1171
1205
  * elsewhere.
1172
1206
  */
@@ -1180,7 +1214,7 @@ const hubTerminalsRestart = defineHubRpcFunction({
1180
1214
  } })
1181
1215
  });
1182
1216
  /**
1183
- * `hub:terminals:remove` — Kill a session's process (when it still owns one)
1217
+ * `hub:terminals:remove`: Kill a session's process (when it still owns one)
1184
1218
  * and drop it from the registry, disposing its output stream. Lets a hub-aware
1185
1219
  * terminal UI discard a stopped aggregated session.
1186
1220
  */
@@ -1196,13 +1230,13 @@ const hubTerminalsRemove = defineHubRpcFunction({
1196
1230
  } })
1197
1231
  });
1198
1232
  /**
1199
- * `hub:docks:activate` — Ask the active viewer to switch its focused dock to
1233
+ * `hub:docks:activate`: Ask the active viewer to switch its focused dock to
1200
1234
  * `dockId`, optionally carrying `params` for the target dock to interpret
1201
1235
  * (e.g. `{ sessionId }` for the terminals dock to focus a session).
1202
1236
  *
1203
1237
  * Any connected client may call it, which is the point: a mounted devframe
1204
1238
  * running in its own iframe (on its own RPC client) can steer the host shell's
1205
- * dock selection — client-local state it otherwise can't reach. The hub
1239
+ * dock selection, client-local state it otherwise can't reach. The hub
1206
1240
  * broadcasts the request live to connected clients (the host shell switches)
1207
1241
  * and mirrors it into the `devframe:docks:active` shared state (a dock that
1208
1242
  * mounts in response still converges on it).
@@ -1303,4 +1337,4 @@ async function createHubContext(options) {
1303
1337
  return context;
1304
1338
  }
1305
1339
  //#endregion
1306
- export { DevframeDocksHost as _, hubMessagesAdd as a, hubMessagesUpdate as c, hubTerminalsRestart as d, hubTerminalsTerminate as f, DevframeMessagesHost as g, DevframeTerminalsHost as h, hubDocksActivate as i, hubTerminalsRemove as l, prepareDevframe as m, builtinHubRpcDeclarations as n, hubMessagesClear as o, hubTerminalsWrite as p, hubCommandsExecute as r, hubMessagesRemove as s, createHubContext as t, hubTerminalsResize as u, DevframeCommandsHost as v, diagnostics as y };
1340
+ export { DevframeCommandsHost as _, hubMessagesAdd as a, diagnostics as b, hubMessagesUpdate as c, hubTerminalsRestart as d, hubTerminalsTerminate as f, DevframeDocksHost as g, DevframeMessagesHost as h, hubDocksActivate as i, hubTerminalsRemove as l, DevframeTerminalsHost as m, builtinHubRpcDeclarations as n, hubMessagesClear as o, hubTerminalsWrite as p, hubCommandsExecute as r, hubMessagesRemove as s, createHubContext as t, hubTerminalsResize as u, prepareDevframe as v, skippedInStaticBuild as y };