@devframes/hub 0.9.7 → 0.9.9

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,35 +1,51 @@
1
1
  import { r as defineHubRpcFunction } from "./define-Ceekw2EO.mjs";
2
- import { n as DEFAULT_STATE_USER_SETTINGS, o as HUB_EVENTS } from "./constants-DrF61GFx.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-BUd6oMci.mjs";
3
+ import { i as isBareModuleSpecifier, t as buildRemoteConnectionUrl } from "./remote-url-UClKLQ5_.mjs";
4
+ import { joinURL, withTrailingSlash } from "ufo";
4
5
  import { createEventEmitter } from "devframe/utils/events";
5
6
  import { createHostContext, createStorage } from "devframe/node";
6
7
  import { getInternalContext, resolveBasePath } from "devframe/node/hub-internals";
7
8
  import { debounce } from "perfect-debounce";
8
- import { coerceAgentPositionalArgs } from "devframe/internal";
9
+ import { coerceAgentPositionalArgs, resolveClientAssets } from "devframe/internal";
9
10
  import { defineDiagnostics } from "devframe/utils/nostics";
10
- import { join, resolve } from "pathe";
11
+ import { basename, dirname, isAbsolute, join, resolve } from "pathe";
11
12
  import { nanoid } from "devframe/utils/nanoid";
12
13
  import process from "node:process";
13
- import { resolveClientAssets } from "devframe";
14
+ import { existsSync } from "node:fs";
14
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
+ */
15
27
  const diagnostics = defineDiagnostics({
16
28
  docsBase: "https://devfra.me/errors",
17
29
  codes: {
18
30
  DF8000: {
19
- 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.`,
20
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`."
21
33
  },
22
34
  DF8002: {
23
- why: "initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.",
24
- 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."
25
37
  },
26
38
  DF8003: {
27
39
  why: "connectionMeta() was called before initHub finished initializing.",
28
- 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."
29
41
  },
30
42
  DF8004: {
31
- why: (p) => `Devframe id "${p.id}" is not a mountable URL segment — the hub mounts each frame at \`<base><id>/\`.`,
32
- 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, 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. Enable the hub's own MCP (pass `mcp` to `initHub`) to surface this devframe's tools, or drop `mcp` from the devframe to silence this warning."
33
49
  },
34
50
  DF8100: {
35
51
  why: (p) => `Dock with id "${p.id}" is already registered`,
@@ -56,12 +72,12 @@ const diagnostics = defineDiagnostics({
56
72
  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."
57
73
  },
58
74
  DF8106: {
59
- 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\`.`,
75
+ 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\`.`,
60
76
  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."
61
77
  },
62
78
  DF8107: {
63
79
  why: (p) => `Dock activation requested for unknown dock id "${p.id}"`,
64
- 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."
80
+ 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."
65
81
  },
66
82
  DF8108: {
67
83
  why: (p) => `A renderer module is already registered for dock type "${p.type}"`,
@@ -72,11 +88,11 @@ const diagnostics = defineDiagnostics({
72
88
  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."
73
89
  },
74
90
  DF8110: {
75
- why: (p) => `Dock type "${p.type}" is not a servable renderer-module name — the hub serves each module at \`<base>__renderers/<type>.mjs\``,
91
+ why: (p) => `Dock type "${p.type}" is not a servable renderer-module name; the hub serves each module at \`<base>__renderers/<type>.mjs\``,
76
92
  fix: "Renderer types become URL segments, so they may only contain letters, digits, `_`, `-`, and `.`. Use a route-safe dock type (e.g. `json-render`)."
77
93
  },
78
94
  DF8111: {
79
- 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.`,
95
+ 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.`,
80
96
  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)."
81
97
  },
82
98
  DF8200: { why: (p) => `Terminal session with id "${p.id}" already registered` },
@@ -88,14 +104,14 @@ const diagnostics = defineDiagnostics({
88
104
  DF8203: { why: (p) => `Failed to spawn PTY session for "${p.command}": ${p.reason}` },
89
105
  DF8204: {
90
106
  why: (p) => `Terminal session "${p.id}" cannot be controlled (no lifecycle handle)`,
91
- fix: "Spawn it via ctx.terminals.startChildProcess() or startPtySession() — sessions added with a bare register() expose no terminate/restart handle."
107
+ fix: "Spawn it via ctx.terminals.startChildProcess() or startPtySession(); sessions added with a bare register() expose no terminate/restart handle."
92
108
  },
93
109
  DF8205: {
94
110
  why: (p) => `Terminal session "${p.id}" is not restartable`,
95
111
  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."
96
112
  },
97
113
  DF8206: {
98
- why: (p) => `Terminal session "${p.id}" cannot be restarted — its output stream is already closed`,
114
+ why: (p) => `Terminal session "${p.id}" cannot be restarted because its output stream is already closed`,
99
115
  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."
100
116
  },
101
117
  DF8400: { why: (p) => `Command "${p.id}" is already registered` },
@@ -144,7 +160,7 @@ var DevframeCommandsHost = class {
144
160
  events = createEventEmitter();
145
161
  /**
146
162
  * Lazy agent projection: `ctx.agent` queries this provider at list/invoke
147
- * time, deriving tools from {@link commands} on demand — the commands map
163
+ * time, deriving tools from {@link commands} on demand, so the commands map
148
164
  * stays the single source of truth, nothing is mirrored or kept in sync.
149
165
  */
150
166
  agentProvider;
@@ -222,7 +238,7 @@ var DevframeCommandsHost = class {
222
238
  * agent-flagged, handler-bearing command (children included) becomes a
223
239
  * callable tool. Queried lazily by the provider registered in the
224
240
  * constructor. `when` clauses evaluate client-side only and are not
225
- * enforced here — opting in a `when`-gated command is a deliberate author
241
+ * enforced here; opting in a `when`-gated command is a deliberate author
226
242
  * decision (documented on `DevframeCommandAgentOptions`).
227
243
  */
228
244
  collectAgentTools() {
@@ -235,7 +251,12 @@ var DevframeCommandsHost = class {
235
251
  description: agent.description,
236
252
  safety: agent.safety ?? "action",
237
253
  tags: agent.tags,
254
+ /** The agent host derives the tool's JSON-Schema input from these. */
238
255
  args: agent.args,
256
+ /**
257
+ * A command handler's positional parameters come solely from its
258
+ * declared `agent.args` schemas; undeclared payload is dropped.
259
+ */
239
260
  handler: async (args) => this.execute(command.id, ...coerceAgentPositionalArgs(args, agent.args, "drop"))
240
261
  });
241
262
  for (const child of command.children ?? []) walk(child);
@@ -265,6 +286,7 @@ var DevframeDocksHost = class {
265
286
  }
266
287
  async init() {
267
288
  this.userSettings = await this.context.rpc.sharedState.get(HUB_EVENTS.sharedState.userSettings, { sharedState: createStorage({
289
+ /** Personal dock layout/preferences: per-checkout private state. */
268
290
  filepath: join(this.context.host.getStorageDir("project"), "settings.json"),
269
291
  initialValue: DEFAULT_STATE_USER_SETTINGS()
270
292
  }) });
@@ -325,7 +347,7 @@ var DevframeDocksHost = class {
325
347
  });
326
348
  }
327
349
  /**
328
- * Warn (don't throw — a client-runtime `resolveClientModule` override may still cover
350
+ * Warn (don't throw, since a client-runtime `resolveClientModule` override may still cover
329
351
  * it) when a dock declares a **bare-specifier** client script on a host
330
352
  * that advertises no `staticConfig.dock.clientModuleResolution`: the
331
353
  * browser cannot resolve a bare npm specifier natively, so the script is
@@ -379,7 +401,7 @@ var DevframeMessagesHost = class {
379
401
  _clock = 0;
380
402
  /**
381
403
  * The tick of the newest removal record dropped from the capped
382
- * `removals` log — cursors older than this can't get a reliable delta
404
+ * `removals` log; cursors older than this can't get a reliable delta
383
405
  * and fall back to a full snapshot in {@link listSince}.
384
406
  */
385
407
  _removalsTrimmedAt = 0;
@@ -560,7 +582,7 @@ var DevframeTerminalsHost = class {
560
582
  this.context = context;
561
583
  }
562
584
  /**
563
- * Lazily acquire the streaming channel — `context.rpc` isn't assigned
585
+ * Lazily acquire the streaming channel, since `context.rpc` isn't assigned
564
586
  * until after every host is constructed, so we can't grab it in the
565
587
  * constructor.
566
588
  */
@@ -570,6 +592,41 @@ var DevframeTerminalsHost = class {
570
592
  this._channel = this.context.rpc.streaming.create(TERMINAL_STREAM_CHANNEL, { replayWindow: TERMINAL_REPLAY_WINDOW });
571
593
  return this._channel;
572
594
  }
595
+ /**
596
+ * Per-run stream lifecycle shared by the child-process and PTY paths: guards
597
+ * the single-close/error transition and keeps the session's `status` in step
598
+ * with the process lifecycle (a hub-aware client sees `running` →
599
+ * `stopped`/`error` instead of a value frozen at spawn time).
600
+ */
601
+ createStreamLifecycle() {
602
+ const state = { streamClosed: false };
603
+ const markStatus = (next) => {
604
+ const session = state.session;
605
+ if (session.status === next) return;
606
+ session.status = next;
607
+ this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
608
+ };
609
+ const closeStream = () => {
610
+ if (state.streamClosed) return;
611
+ state.streamClosed = true;
612
+ try {
613
+ state.controller?.close();
614
+ } catch {}
615
+ };
616
+ const errorStream = (error) => {
617
+ if (state.streamClosed) return;
618
+ state.streamClosed = true;
619
+ try {
620
+ state.controller?.error(error);
621
+ } catch {}
622
+ };
623
+ return {
624
+ state,
625
+ markStatus,
626
+ closeStream,
627
+ errorStream
628
+ };
629
+ }
573
630
  register(session) {
574
631
  if (this.sessions.has(session.id)) throw diagnostics.DF8200({ id: session.id });
575
632
  this.sessions.set(session.id, session);
@@ -591,6 +648,25 @@ var DevframeTerminalsHost = class {
591
648
  this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
592
649
  this._boundStreams.delete(session.id);
593
650
  }
651
+ /** Drain a session's output stream into its scrollback buffer and streaming sink. */
652
+ async pumpStream(reader, sessionBuffer, sink, isDisposed) {
653
+ try {
654
+ while (!isDisposed()) {
655
+ const result = await reader.read();
656
+ if (isDisposed() || result.done) break;
657
+ sessionBuffer.push(result.value);
658
+ if (sessionBuffer.length > TERMINAL_BUFFER_LIMIT) sessionBuffer.splice(0, sessionBuffer.length - TERMINAL_BUFFER_LIMIT);
659
+ sink?.write(result.value);
660
+ }
661
+ if (!isDisposed() && sink && !sink.closed) sink.close();
662
+ } catch (error) {
663
+ if (!isDisposed() && sink && !sink.closed) sink.error(error);
664
+ } finally {
665
+ try {
666
+ reader.releaseLock();
667
+ } catch {}
668
+ }
669
+ }
594
670
  bindStream(session) {
595
671
  if (this._boundStreams.has(session.id) && this._boundStreams.get(session.id)?.stream === session.stream) return;
596
672
  this._boundStreams.get(session.id)?.dispose();
@@ -601,26 +677,7 @@ var DevframeTerminalsHost = class {
601
677
  const sink = this.getStreamingChannel()?.start({ id: session.id });
602
678
  const reader = session.stream.getReader();
603
679
  let disposed = false;
604
- (async () => {
605
- try {
606
- while (true) {
607
- if (disposed) break;
608
- const result = await reader.read();
609
- if (disposed) break;
610
- if (result.done) break;
611
- sessionBuffer.push(result.value);
612
- if (sessionBuffer.length > TERMINAL_BUFFER_LIMIT) sessionBuffer.splice(0, sessionBuffer.length - TERMINAL_BUFFER_LIMIT);
613
- sink?.write(result.value);
614
- }
615
- if (!disposed && sink && !sink.closed) sink.close();
616
- } catch (error) {
617
- if (!disposed && sink && !sink.closed) sink.error(error);
618
- } finally {
619
- try {
620
- reader.releaseLock();
621
- } catch {}
622
- }
623
- })();
680
+ this.pumpStream(reader, sessionBuffer, sink, () => disposed);
624
681
  this._boundStreams.set(session.id, {
625
682
  dispose: () => {
626
683
  disposed = true;
@@ -633,34 +690,13 @@ var DevframeTerminalsHost = class {
633
690
  async startChildProcess(executeOptions, terminal) {
634
691
  if (this.sessions.has(terminal.id)) throw diagnostics.DF8200({ id: terminal.id });
635
692
  const { exec } = await import("tinyexec");
636
- let controller;
693
+ const { state, markStatus, closeStream, errorStream } = this.createStreamLifecycle();
637
694
  let cp;
638
695
  let currentResult;
639
696
  let runId = 0;
640
- let streamClosed = false;
641
- let session;
642
- const markStatus = (next) => {
643
- if (session.status === next) return;
644
- session.status = next;
645
- this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
646
- };
647
- const closeStream = () => {
648
- if (streamClosed) return;
649
- streamClosed = true;
650
- try {
651
- controller?.close();
652
- } catch {}
653
- };
654
- const errorStream = (error) => {
655
- if (streamClosed) return;
656
- streamClosed = true;
657
- try {
658
- controller?.error(error);
659
- } catch {}
660
- };
661
697
  const stream = new ReadableStream({
662
698
  start(_controller) {
663
- controller = _controller;
699
+ state.controller = _controller;
664
700
  },
665
701
  cancel() {
666
702
  cp?.kill();
@@ -700,13 +736,13 @@ var DevframeTerminalsHost = class {
700
736
  if (currentRun !== runId) return;
701
737
  const text = chunk.toString();
702
738
  stdoutChunks.push(text);
703
- if (!streamClosed) controller?.enqueue(text);
739
+ if (!state.streamClosed) state.controller?.enqueue(text);
704
740
  });
705
741
  cp.process?.stderr?.on("data", (chunk) => {
706
742
  if (currentRun !== runId) return;
707
743
  const text = chunk.toString();
708
744
  stderrChunks.push(text);
709
- if (!streamClosed) controller?.enqueue(text);
745
+ if (!state.streamClosed) state.controller?.enqueue(text);
710
746
  });
711
747
  cp.process?.once("error", (error) => {
712
748
  if (currentRun !== runId) return;
@@ -738,7 +774,7 @@ var DevframeTerminalsHost = class {
738
774
  }
739
775
  cp = createChildProcess();
740
776
  const restart = async () => {
741
- if (streamClosed) throw diagnostics.DF8206({ id: terminal.id });
777
+ if (state.streamClosed) throw diagnostics.DF8206({ id: terminal.id });
742
778
  cp?.kill();
743
779
  cp = createChildProcess();
744
780
  markStatus("running");
@@ -749,7 +785,7 @@ var DevframeTerminalsHost = class {
749
785
  closeStream();
750
786
  markStatus("stopped");
751
787
  };
752
- session = {
788
+ const session = {
753
789
  ...terminal,
754
790
  status: "running",
755
791
  stream,
@@ -760,6 +796,7 @@ var DevframeTerminalsHost = class {
760
796
  terminate,
761
797
  restart
762
798
  };
799
+ state.session = session;
763
800
  this.register(session);
764
801
  return Promise.resolve(session);
765
802
  }
@@ -768,43 +805,24 @@ var DevframeTerminalsHost = class {
768
805
  const { spawn } = await import("zigpty");
769
806
  const cols = executeOptions.cols ?? 80;
770
807
  const rows = executeOptions.rows ?? 24;
771
- let controller;
808
+ const { state, markStatus, closeStream, errorStream } = this.createStreamLifecycle();
772
809
  let pty;
810
+ let currentResult;
811
+ let killCurrentRun;
773
812
  let runId = 0;
774
- let streamClosed = false;
775
- let session;
776
- const markStatus = (next) => {
777
- if (session.status === next) return;
778
- session.status = next;
779
- this.events.emit(HUB_EVENTS.bus.terminalsSessionUpdated, session);
780
- };
781
- const closeStream = () => {
782
- if (streamClosed) return;
783
- streamClosed = true;
784
- try {
785
- controller?.close();
786
- } catch {}
787
- };
788
- const errorStream = (error) => {
789
- if (streamClosed) return;
790
- streamClosed = true;
791
- try {
792
- controller?.error(error);
793
- } catch {}
794
- };
795
813
  const stream = new ReadableStream({
796
814
  start(_controller) {
797
- controller = _controller;
815
+ state.controller = _controller;
798
816
  },
799
817
  cancel() {
800
- pty?.kill();
818
+ killCurrentRun?.();
801
819
  pty = void 0;
802
820
  closeStream();
803
821
  }
804
822
  });
805
823
  const spawnPty = () => {
806
824
  const currentRun = ++runId;
807
- const proc = spawn(executeOptions.command, executeOptions.args ?? [], {
825
+ const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], {
808
826
  name: PTY_TERM_NAME,
809
827
  cols,
810
828
  rows,
@@ -817,16 +835,58 @@ var DevframeTerminalsHost = class {
817
835
  ...executeOptions.env ?? {}
818
836
  }
819
837
  });
820
- proc.onData((data) => {
821
- if (streamClosed || currentRun !== runId) return;
822
- controller?.enqueue(typeof data === "string" ? data : data.toString("utf8"));
838
+ const outputChunks = [];
839
+ let killed = false;
840
+ let settled = false;
841
+ let settledExitCode;
842
+ let resolveOutput;
843
+ const outputPromise = new Promise((resolve) => {
844
+ resolveOutput = resolve;
823
845
  });
824
- proc.onExit(({ exitCode, signal }) => {
846
+ const settle = (exitCode, signal) => {
847
+ if (settled) return;
848
+ settled = true;
849
+ killed ||= signal !== 0;
850
+ settledExitCode = killed ? void 0 : exitCode;
851
+ resolveOutput({
852
+ output: outputChunks.join(""),
853
+ exitCode: settledExitCode,
854
+ signal: signal === 0 ? void 0 : signal
855
+ });
856
+ };
857
+ ptyProcess.onData((data) => {
858
+ const text = typeof data === "string" ? data : data.toString("utf8");
859
+ outputChunks.push(text);
860
+ if (!state.streamClosed && currentRun === runId) state.controller?.enqueue(text);
861
+ });
862
+ ptyProcess.onExit(({ exitCode, signal }) => {
863
+ settle(exitCode, signal);
825
864
  if (currentRun !== runId) return;
826
865
  closeStream();
827
- markStatus(signal === 0 && exitCode !== 0 ? "error" : "stopped");
866
+ /**
867
+ * Killed runs and clean exits are stopped. Only a non-killed non-zero exit
868
+ * code is a crash, matching the child-process path.
869
+ */
870
+ markStatus(!killed && exitCode !== 0 ? "error" : "stopped");
828
871
  });
829
- return proc;
872
+ currentResult = {
873
+ get pid() {
874
+ return ptyProcess.pid;
875
+ },
876
+ get exitCode() {
877
+ return killed ? void 0 : ptyProcess.exitCode ?? settledExitCode;
878
+ },
879
+ get killed() {
880
+ return killed;
881
+ },
882
+ then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected)
883
+ };
884
+ killCurrentRun = () => {
885
+ if (ptyProcess.exitCode !== null) return;
886
+ killed = true;
887
+ ptyProcess.kill();
888
+ };
889
+ return ptyProcess;
830
890
  };
831
891
  try {
832
892
  pty = spawnPty();
@@ -837,7 +897,7 @@ var DevframeTerminalsHost = class {
837
897
  reason: error instanceof Error ? error.message : String(error)
838
898
  });
839
899
  }
840
- session = {
900
+ const session = {
841
901
  ...terminal,
842
902
  status: "running",
843
903
  interactive: true,
@@ -862,19 +922,31 @@ var DevframeTerminalsHost = class {
862
922
  return;
863
923
  }
864
924
  },
925
+ getResult: () => currentResult,
865
926
  terminate: async () => {
866
- pty?.kill();
927
+ killCurrentRun?.();
867
928
  pty = void 0;
868
929
  closeStream();
869
930
  markStatus("stopped");
870
931
  },
871
932
  restart: async () => {
872
- if (streamClosed) throw diagnostics.DF8206({ id: terminal.id });
873
- pty?.kill();
874
- pty = spawnPty();
933
+ if (state.streamClosed) throw diagnostics.DF8206({ id: terminal.id });
934
+ killCurrentRun?.();
935
+ killCurrentRun = void 0;
936
+ pty = void 0;
937
+ try {
938
+ pty = spawnPty();
939
+ } catch (error) {
940
+ markStatus("error");
941
+ throw diagnostics.DF8203({
942
+ command: executeOptions.command,
943
+ reason: error instanceof Error ? error.message : String(error)
944
+ });
945
+ }
875
946
  markStatus("running");
876
947
  }
877
948
  };
949
+ state.session = session;
878
950
  this.register(session);
879
951
  return session;
880
952
  }
@@ -894,7 +966,23 @@ function nextAvailableDockId(views, baseId) {
894
966
  return `${baseId}-${n}`;
895
967
  }
896
968
  /**
897
- * Framework-neutral primitive backing {@link DevframeHubContext.install} —
969
+ * When a dock's `clientScript.importFrom` is an absolute filesystem path, serve
970
+ * its directory under `<base>__page-script/` and rewrite `importFrom` to that
971
+ * URL. A URL or bare specifier (not existing on disk) passes through untouched.
972
+ */
973
+ async function resolvePageScriptClientScript(ctx, clientScript, base) {
974
+ if (!clientScript?.importFrom) return clientScript;
975
+ const { importFrom } = clientScript;
976
+ if (!isAbsolute(importFrom) || !existsSync(importFrom)) return clientScript;
977
+ const scriptBase = withTrailingSlash(joinURL(base, "__page-script"));
978
+ await ctx.host.mountStatic(scriptBase, dirname(importFrom));
979
+ return {
980
+ ...clientScript,
981
+ importFrom: joinURL(scriptBase, basename(importFrom))
982
+ };
983
+ }
984
+ /**
985
+ * Framework-neutral primitive backing {@link DevframeHubContext.install} -
898
986
  * installs a {@link DevframeDefinition} as a dock inside a hub-aware context:
899
987
  * serves the devframe's SPA at the resolved base path, synthesizes an iframe
900
988
  * dock entry from the definition's metadata, and runs the definition's
@@ -902,20 +990,35 @@ function nextAvailableDockId(views, baseId) {
902
990
  * calling it directly.
903
991
  *
904
992
  * Framework kits wrap `ctx.install` with their own plugin/middleware
905
- * machinery — e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
993
+ * machinery, e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
906
994
  * returns a Vite `Plugin` whose `devtools.setup` ultimately delegates here.
907
995
  */
908
996
  /**
909
997
  * Phase one of an install: run the duplication guard, serve the SPA + meta,
910
998
  * register the iframe dock, and queue the definition's declarative wire
911
- * services — everything up to (but not including) `setup(ctx)`. Returns a
999
+ * services, everything up to (but not including) `setup(ctx)`. Returns a
912
1000
  * deferred setup thunk, or `null` when the devframe was deduplicated.
913
1001
  *
914
1002
  * The hub's initial batch uses this to collect every devframe's services
915
- * across the whole hub, `ready()` them once, and only then run the setups —
1003
+ * across the whole hub, `ready()` them once, and only then run the setups,
916
1004
  * so services are ready before any setup, and a plugin can consume a service
917
1005
  * another plugin declared regardless of mount order.
918
1006
  */
1007
+ /**
1008
+ * Serve a devframe's SPA (and the hub's connection meta) under `base`, if the
1009
+ * definition ships client assets.
1010
+ */
1011
+ async function serveDevframeAssets(ctx, d, id, base) {
1012
+ const clientAssets = resolveClientAssets(d);
1013
+ if (!clientAssets) return;
1014
+ if (ctx.host.mountConnectionMeta) await ctx.host.mountConnectionMeta(base);
1015
+ else diagnostics.DF8106({
1016
+ id,
1017
+ name: d.name,
1018
+ base
1019
+ });
1020
+ ctx.views.hostStatic(base, typeof clientAssets === "string" ? resolve(clientAssets) : clientAssets, d.importMetaUrl);
1021
+ }
919
1022
  async function prepareDevframe(ctx, d, options = {}) {
920
1023
  const strategy = d.duplicationStrategy ?? "warn";
921
1024
  const isDuplicate = ctx.docks.views.has(d.id);
@@ -936,23 +1039,18 @@ async function prepareDevframe(ctx, d, options = {}) {
936
1039
  id,
937
1040
  basePath: void 0
938
1041
  }, "hosted"));
939
- const clientAssets = resolveClientAssets(d);
940
- if (clientAssets) {
941
- if (ctx.host.mountConnectionMeta) await ctx.host.mountConnectionMeta(base);
942
- else diagnostics.DF8106({
943
- id,
944
- name: d.name,
945
- base
946
- });
947
- const distSource = clientAssets;
948
- ctx.views.hostStatic(base, typeof distSource === "string" ? resolve(distSource) : distSource, d.importMetaUrl);
949
- }
1042
+ const dockDefaults = {
1043
+ ...d.dock,
1044
+ ...options.dock
1045
+ };
1046
+ const clientScript = await resolvePageScriptClientScript(ctx, dockDefaults.clientScript, base);
1047
+ if (clientScript) dockDefaults.clientScript = clientScript;
1048
+ await serveDevframeAssets(ctx, d, id, base);
950
1049
  ctx.docks.register({
951
1050
  id,
952
1051
  title: d.name,
953
1052
  icon: d.icon,
954
- ...d.dock,
955
- ...options.dock,
1053
+ ...dockDefaults,
956
1054
  type: "iframe",
957
1055
  url: base
958
1056
  });
@@ -960,7 +1058,7 @@ async function prepareDevframe(ctx, d, options = {}) {
960
1058
  return () => Promise.resolve(d.setup(ctx));
961
1059
  }
962
1060
  /**
963
- * Install a {@link DevframeDefinition} into a hub in one call — serve its SPA,
1061
+ * Install a {@link DevframeDefinition} into a hub in one call: serve its SPA,
964
1062
  * register its dock, ready its services, and run `setup(ctx)`. The imperative
965
1063
  * counterpart to the hub's declarative `devframes` list (which batches the
966
1064
  * phases via {@link prepareDevframe}); use it from `configure(ctx)` or
@@ -996,7 +1094,7 @@ function resolveControllableSession(sessions, id) {
996
1094
  return session;
997
1095
  }
998
1096
  /**
999
- * `hub:commands:execute` — Invoke a registered server command by id. The
1097
+ * `hub:commands:execute`: Invoke a registered server command by id. The
1000
1098
  * arguments after `id` are forwarded to the command's `handler(...)`.
1001
1099
  * Returns whatever the handler returns.
1002
1100
  *
@@ -1011,7 +1109,7 @@ const hubCommandsExecute = defineHubRpcFunction({
1011
1109
  } })
1012
1110
  });
1013
1111
  /**
1014
- * `hub:messages:add` — Add a message from a browser client into the hub's
1112
+ * `hub:messages:add`: Add a message from a browser client into the hub's
1015
1113
  * messages subsystem. Marked `from: 'browser'`. Returns the serializable
1016
1114
  * entry (the mutation handle stays server-side).
1017
1115
  *
@@ -1030,7 +1128,7 @@ const hubMessagesAdd = defineHubRpcFunction({
1030
1128
  })).entry;
1031
1129
  } })
1032
1130
  });
1033
- /** `hub:messages:update` — Patch a message by id; returns the updated entry (or `undefined`). */
1131
+ /** `hub:messages:update`: Patch a message by id; returns the updated entry (or `undefined`). */
1034
1132
  const hubMessagesUpdate = defineHubRpcFunction({
1035
1133
  name: HUB_EVENTS.rpc.messagesUpdate,
1036
1134
  type: "action",
@@ -1039,7 +1137,7 @@ const hubMessagesUpdate = defineHubRpcFunction({
1039
1137
  return context.messages.update(id, patch);
1040
1138
  } })
1041
1139
  });
1042
- /** `hub:messages:remove` — Remove a message by id. */
1140
+ /** `hub:messages:remove`: Remove a message by id. */
1043
1141
  const hubMessagesRemove = defineHubRpcFunction({
1044
1142
  name: HUB_EVENTS.rpc.messagesRemove,
1045
1143
  type: "action",
@@ -1047,7 +1145,7 @@ const hubMessagesRemove = defineHubRpcFunction({
1047
1145
  await context.messages.remove(id);
1048
1146
  } })
1049
1147
  });
1050
- /** `hub:messages:clear` — Remove every message. */
1148
+ /** `hub:messages:clear`: Remove every message. */
1051
1149
  const hubMessagesClear = defineHubRpcFunction({
1052
1150
  name: HUB_EVENTS.rpc.messagesClear,
1053
1151
  type: "action",
@@ -1056,7 +1154,7 @@ const hubMessagesClear = defineHubRpcFunction({
1056
1154
  } })
1057
1155
  });
1058
1156
  /**
1059
- * `hub:terminals:write` — Send input to an interactive PTY session spawned
1157
+ * `hub:terminals:write`: Send input to an interactive PTY session spawned
1060
1158
  * via `ctx.terminals.startPtySession`. Lets a hub-aware terminal UI (e.g. the
1061
1159
  * terminals plugin) drive a session owned by another plugin.
1062
1160
  */
@@ -1067,7 +1165,7 @@ const hubTerminalsWrite = defineHubRpcFunction({
1067
1165
  resolveInteractiveSession(context.terminals.sessions, id).write(data);
1068
1166
  } })
1069
1167
  });
1070
- /** `hub:terminals:resize` — Resize an interactive PTY session by id. */
1168
+ /** `hub:terminals:resize`: Resize an interactive PTY session by id. */
1071
1169
  const hubTerminalsResize = defineHubRpcFunction({
1072
1170
  name: HUB_EVENTS.rpc.terminalsResize,
1073
1171
  type: "action",
@@ -1076,7 +1174,7 @@ const hubTerminalsResize = defineHubRpcFunction({
1076
1174
  } })
1077
1175
  });
1078
1176
  /**
1079
- * `hub:terminals:terminate` — Kill a session's process while keeping the
1177
+ * `hub:terminals:terminate`: Kill a session's process while keeping the
1080
1178
  * session registered (its output/scrollback stays). Works for both read-only
1081
1179
  * child-process and interactive PTY sessions, letting a hub-aware terminal UI
1082
1180
  * force-kill a session owned by another plugin.
@@ -1089,7 +1187,7 @@ const hubTerminalsTerminate = defineHubRpcFunction({
1089
1187
  } })
1090
1188
  });
1091
1189
  /**
1092
- * `hub:terminals:restart` — Re-run a session's command in place. Rejected for
1190
+ * `hub:terminals:restart`: Re-run a session's command in place. Rejected for
1093
1191
  * sessions registered with `restartable: false`, whose lifecycle is owned
1094
1192
  * elsewhere.
1095
1193
  */
@@ -1103,7 +1201,7 @@ const hubTerminalsRestart = defineHubRpcFunction({
1103
1201
  } })
1104
1202
  });
1105
1203
  /**
1106
- * `hub:terminals:remove` — Kill a session's process (when it still owns one)
1204
+ * `hub:terminals:remove`: Kill a session's process (when it still owns one)
1107
1205
  * and drop it from the registry, disposing its output stream. Lets a hub-aware
1108
1206
  * terminal UI discard a stopped aggregated session.
1109
1207
  */
@@ -1119,13 +1217,13 @@ const hubTerminalsRemove = defineHubRpcFunction({
1119
1217
  } })
1120
1218
  });
1121
1219
  /**
1122
- * `hub:docks:activate` — Ask the active viewer to switch its focused dock to
1220
+ * `hub:docks:activate`: Ask the active viewer to switch its focused dock to
1123
1221
  * `dockId`, optionally carrying `params` for the target dock to interpret
1124
1222
  * (e.g. `{ sessionId }` for the terminals dock to focus a session).
1125
1223
  *
1126
1224
  * Any connected client may call it, which is the point: a mounted devframe
1127
1225
  * running in its own iframe (on its own RPC client) can steer the host shell's
1128
- * dock selection — client-local state it otherwise can't reach. The hub
1226
+ * dock selection, client-local state it otherwise can't reach. The hub
1129
1227
  * broadcasts the request live to connected clients (the host shell switches)
1130
1228
  * and mirrors it into the `devframe:docks:active` shared state (a dock that
1131
1229
  * mounts in response still converges on it).