@uniqbit/mate-core 0.17.0-canary.8 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -1,20 +1,74 @@
1
+ import {
2
+ createStudioAccess,
3
+ exchangeStudioToken,
4
+ studioAddress,
5
+ type StudioAccess,
6
+ type StudioInvocation,
7
+ } from "./access";
1
8
  import { collectStudioInventory, type StudioInventory } from "./inventory";
2
9
  import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
3
10
  import { STUDIO_HOSTNAME } from "./routes";
4
- import { parseStudioSelection, resolveCompanion } from "./selection";
11
+ import {
12
+ companionDigest,
13
+ parse,
14
+ parseVaultSelection,
15
+ resolveCompanion,
16
+ type StudioVaultSelection,
17
+ } from "./selection";
5
18
  import { createStudioSnapshotCache, type StudioSnapshotCache } from "./snapshot";
6
- import type { StudioPage } from "./views/model";
19
+ import type { StudioPage, StudioVaultPage } from "./views/model";
7
20
  import { createVaultManager, resolveVaultPath, VaultPathError, type VaultManager } from "./vault";
21
+ import {
22
+ DEFAULT_DETACH_MINUTES,
23
+ TerminalRegistry,
24
+ type TerminalConnection,
25
+ type TerminalRegistryOptions,
26
+ } from "./terminal";
27
+ import { terminalAsset } from "./terminal-assets";
28
+ import {
29
+ createLaunchResolver,
30
+ effectiveLaunchCompanion,
31
+ launchableAgents,
32
+ } from "./terminal-launch";
8
33
 
9
34
  export { STUDIO_HOSTNAME } from "./routes";
10
35
 
11
36
  /** Read methods; every other method is refused before any collection runs. */
12
37
  const READ_METHODS = new Set(["GET", "HEAD"]);
13
38
 
39
+ /** The socket Bun hands a WebSocket handler; `data` is what the upgrade attached. */
40
+ export interface StudioSocket {
41
+ data: { connection?: TerminalConnection };
42
+ send(data: string | Uint8Array): number;
43
+ getBufferedAmount(): number;
44
+ ping(): void;
45
+ close(code?: number, reason?: string): void;
46
+ }
47
+
48
+ export interface StudioUpgrader {
49
+ upgrade(request: Request, options: { data: StudioSocket["data"] }): boolean;
50
+ /** Per-request idle timeout in seconds; `0` disables it. */
51
+ timeout?(request: Request, seconds: number): void;
52
+ }
53
+
54
+ /** Bun's maximum; its 10 s default cut slow Vault listings into empty responses. */
55
+ export const STUDIO_IDLE_TIMEOUT_SECONDS = 255;
56
+
14
57
  export interface StudioServeOptions {
15
58
  port: number;
16
59
  hostname: string;
17
- fetch: (request: Request) => Promise<Response>;
60
+ idleTimeout: number;
61
+ fetch: (request: Request, server: StudioUpgrader) => Promise<Response | undefined>;
62
+ websocket?: {
63
+ open(ws: StudioSocket): void;
64
+ message(ws: StudioSocket, message: string | Uint8Array): void;
65
+ close(ws: StudioSocket): void;
66
+ pong(ws: StudioSocket): void;
67
+ idleTimeout: number;
68
+ sendPings: boolean;
69
+ backpressureLimit: number;
70
+ maxPayloadLength: number;
71
+ };
18
72
  }
19
73
 
20
74
  export interface StudioBoundServer {
@@ -24,6 +78,8 @@ export interface StudioBoundServer {
24
78
 
25
79
  export interface StudioServerHandle {
26
80
  url: string;
81
+ /** What the operator opens: `url`, carrying the token when one was generated. */
82
+ address: string;
27
83
  port: number;
28
84
  hostname: string;
29
85
  stop(): void | Promise<void>;
@@ -36,12 +92,27 @@ export interface StudioServerDeps {
36
92
  serve?: (options: StudioServeOptions) => StudioBoundServer;
37
93
  snapshots?: StudioSnapshotCache;
38
94
  vault?: VaultManager;
95
+ terminal?: Partial<TerminalRegistryOptions>;
96
+ launchableAgents?: typeof launchableAgents;
39
97
  }
40
98
 
41
99
  export interface StudioServerOptions {
42
100
  port?: number;
43
101
  hostname?: string;
44
102
  writable?: boolean;
103
+ invocation?: StudioInvocation;
104
+ terminal?: boolean;
105
+ allowedHosts?: string[];
106
+ publicOrigin?: string | null;
107
+ /** Operator-pinned token; generated per start when absent and Studio is guarded. */
108
+ token?: string | null;
109
+ detachMinutes?: number;
110
+ /** Pins every terminal launch to this registered companion. */
111
+ launchCompanion?: string | null;
112
+ /** Seconds; defaults to `STUDIO_IDLE_TIMEOUT_SECONDS`. */
113
+ idleTimeout?: number;
114
+ /** Terminal launches skip companion Git synchronization. */
115
+ noGit?: boolean;
45
116
  }
46
117
 
47
118
  /**
@@ -53,12 +124,51 @@ async function renderStudioDocument(page: StudioPage): Promise<string> {
53
124
  return views.renderStudioDocument(page);
54
125
  }
55
126
 
127
+ async function renderVaultView(page: StudioPage): Promise<string> {
128
+ const views = await import("./views/document");
129
+ return views.renderVaultView(page);
130
+ }
131
+
132
+ /** Keep the CLI entry free of eager Studio view imports. */
133
+ async function collectVaultState(
134
+ vault: VaultManager,
135
+ companionPath: string,
136
+ selection: StudioVaultSelection,
137
+ options: { tree: "prefetch" | "await" },
138
+ ): Promise<StudioVaultPage> {
139
+ const { vaultState } = await import("./views/vault/state");
140
+ return vaultState(vault, companionPath, selection, options);
141
+ }
142
+
56
143
  function html(body: string): Response {
57
144
  return new Response(body, {
58
145
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
59
146
  });
60
147
  }
61
148
 
149
+ /** Answered to a tokenless document request under `serve`; carries no companion data. */
150
+ const TOKEN_REQUIRED_PAGE = `<!doctype html>
151
+ <html lang="en"><head><meta charset="utf-8"><title>Mate Studio</title>
152
+ <meta name="referrer" content="no-referrer"></head>
153
+ <body><main><h1>Mate Studio needs its access token</h1>
154
+ <p>Open the address Studio printed when it started; it carries <code>?token=…</code>.
155
+ If the token was pinned with <code>--token</code> or <code>MATE_STUDIO_TOKEN</code>,
156
+ open this address with <code>?token=</code> followed by that value.</p></main></body></html>`;
157
+
158
+ function refused(reason: string, status: number): Response {
159
+ return new Response(JSON.stringify({ reason }), {
160
+ status,
161
+ headers: {
162
+ "content-type": "application/json; charset=utf-8",
163
+ "cache-control": "no-store",
164
+ "referrer-policy": "no-referrer",
165
+ },
166
+ });
167
+ }
168
+
169
+ const TOKEN_REASON =
170
+ "Studio's access token is required; open the address Studio printed when it started";
171
+
62
172
  function json(value: unknown, status = 200): Response {
63
173
  return new Response(JSON.stringify(value), {
64
174
  status,
@@ -67,8 +177,18 @@ function json(value: unknown, status = 200): Response {
67
177
  }
68
178
 
69
179
  interface StudioFetch {
70
- (request: Request): Promise<Response>;
71
- close(): void;
180
+ (request: Request, server?: StudioUpgrader): Promise<Response | undefined>;
181
+ terminal: TerminalRegistry | null;
182
+ close(): Promise<void>;
183
+ }
184
+
185
+ export interface StudioFetchOptions extends Pick<
186
+ StudioServerOptions,
187
+ "writable" | "launchCompanion" | "noGit" | "detachMinutes"
188
+ > {
189
+ access?: StudioAccess;
190
+ /** Enables the terminal; the invocation sets its default detach window. */
191
+ terminal?: boolean;
72
192
  }
73
193
 
74
194
  /**
@@ -82,7 +202,7 @@ interface StudioFetch {
82
202
  */
83
203
  export function createStudioFetch(
84
204
  deps: StudioServerDeps = {},
85
- options: Pick<StudioServerOptions, "writable"> = {},
205
+ options: StudioFetchOptions = {},
86
206
  ): StudioFetch {
87
207
  const inventory = deps.collectStudioInventory ?? collectStudioInventory;
88
208
  const companion = deps.assembleCompanionPayload ?? assembleCompanionPayload;
@@ -91,17 +211,54 @@ export function createStudioFetch(
91
211
  deps.snapshots ?? createStudioSnapshotCache({ assembleCompanionPayload: companion });
92
212
  const vault = deps.vault ?? createVaultManager();
93
213
  const writable = options.writable === true;
94
-
95
- const handler = async (request: Request): Promise<Response> => {
214
+ const access =
215
+ options.access ??
216
+ createStudioAccess({
217
+ invocation: "interactive",
218
+ terminal: options.terminal === true,
219
+ hostname: STUDIO_HOSTNAME,
220
+ port: () => 80,
221
+ });
222
+ const launchCompanion = options.launchCompanion ?? null;
223
+ const agentsFor = deps.launchableAgents ?? launchableAgents;
224
+ const terminal = options.terminal
225
+ ? new TerminalRegistry({
226
+ detachMs: (options.detachMinutes ?? DEFAULT_DETACH_MINUTES[access.invocation]) * 60_000,
227
+ resolveLaunch: createLaunchResolver({
228
+ collectInventory: inventory,
229
+ launchCompanion,
230
+ launchableAgents: (companionPath) => agentsFor(companionPath),
231
+ }),
232
+ mateCommand: process.argv.slice(0, 2),
233
+ noGit: options.noGit === true,
234
+ ...deps.terminal,
235
+ })
236
+ : null;
237
+
238
+ const handler = async (
239
+ request: Request,
240
+ server?: StudioUpgrader,
241
+ ): Promise<Response | undefined> => {
96
242
  const url = new URL(request.url);
243
+ const acceptedOrigin = access.acceptedOrigin(request);
244
+ if (!acceptedOrigin) return refused("Studio does not answer to this host", 421);
245
+
246
+ const exchanged = exchangeStudioToken(request, access, acceptedOrigin);
247
+ if (exchanged) return exchanged;
248
+
97
249
  const isSave = url.pathname === "/api/vault/save";
98
- if (isSave && request.method !== "POST") {
99
- return new Response("vault save requires POST", { status: 405, headers: { allow: "POST" } });
250
+ const isEnd = url.pathname === "/api/terminal/sessions/end";
251
+ const isPost = isSave || isEnd;
252
+ if (isPost && request.method !== "POST") {
253
+ return new Response(`${isSave ? "vault save" : "ending a session"} requires POST`, {
254
+ status: 405,
255
+ headers: { allow: "POST" },
256
+ });
100
257
  }
101
- if (!READ_METHODS.has(request.method) && !(isSave && request.method === "POST")) {
258
+ if (!READ_METHODS.has(request.method) && !(isPost && request.method === "POST")) {
102
259
  return new Response("studio serves read requests only", {
103
260
  status: 405,
104
- headers: { allow: isSave ? "POST" : "GET, HEAD" },
261
+ headers: { allow: isPost ? "POST" : "GET, HEAD" },
105
262
  });
106
263
  }
107
264
 
@@ -110,13 +267,75 @@ export function createStudioFetch(
110
267
  ? new Response(null, { status: response.status, headers: response.headers })
111
268
  : response;
112
269
 
270
+ if (access.requires("read") && !access.hasToken(request)) {
271
+ if (url.pathname === "/") {
272
+ const page = html(TOKEN_REQUIRED_PAGE);
273
+ return respond(new Response(page.body, { status: 401, headers: page.headers }));
274
+ }
275
+ return respond(refused(TOKEN_REASON, 401));
276
+ }
277
+
278
+ if (url.pathname.startsWith("/studio/terminal/") && terminal) {
279
+ const asset = await terminalAsset(url.pathname);
280
+ if (asset) return respond(asset);
281
+ }
282
+
283
+ if (url.pathname === "/api/terminal" || url.pathname.startsWith("/api/terminal/")) {
284
+ if (!terminal) {
285
+ return respond(
286
+ refused("start Studio with --writable --terminal to enable the terminal", 404),
287
+ );
288
+ }
289
+ const privileged = url.pathname === "/api/terminal" || isEnd;
290
+ if (privileged && !access.originMatches(request, acceptedOrigin)) {
291
+ return respond(refused("the terminal is reachable only from Studio's own page", 403));
292
+ }
293
+ if (access.requires("terminal") && !access.hasToken(request)) {
294
+ return respond(refused(TOKEN_REASON, 401));
295
+ }
296
+ if (url.pathname === "/api/terminal") {
297
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket" || !server) {
298
+ return respond(refused("the terminal is a WebSocket endpoint", 426));
299
+ }
300
+ if (server.upgrade(request, { data: {} })) return undefined;
301
+ return respond(refused("the terminal connection could not be upgraded", 400));
302
+ }
303
+ if (url.pathname === "/api/terminal/sessions") {
304
+ const now = Date.now();
305
+ return respond(
306
+ json({
307
+ sessions: terminal.list().map((session) => ({
308
+ id: session.id,
309
+ agent: session.agent,
310
+ companion: companionDigest(session.companionPath),
311
+ companionPath: session.companionPath,
312
+ attached: session.attached,
313
+ ageSeconds: Math.max(0, Math.round((now - session.startedAt) / 1000)),
314
+ })),
315
+ }),
316
+ );
317
+ }
318
+ if (isEnd) {
319
+ let body: { id?: unknown };
320
+ try {
321
+ body = (await request.json()) as typeof body;
322
+ } catch {
323
+ return respond(json({ reason: "ending a session requires a JSON body" }, 400));
324
+ }
325
+ if (typeof body.id !== "string" || !(await terminal.end(body.id))) {
326
+ return respond(json({ reason: "no such session" }, 404));
327
+ }
328
+ return respond(json({ ended: body.id }));
329
+ }
330
+ return respond(new Response("not found", { status: 404 }));
331
+ }
332
+
113
333
  if (url.pathname === "/api/vault/tree") {
114
- const selected = await selectedCompanion(url, inventory);
334
+ const selection = parseVaultSelection(url);
335
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
115
336
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
116
337
  try {
117
- return respond(
118
- json(await vault.tree(selected.path, url.searchParams.get("refresh") === "1")),
119
- );
338
+ return respond(json(await vault.tree(selected.path, selection.refresh)));
120
339
  } catch (error) {
121
340
  return respond(
122
341
  json({ reason: error instanceof Error ? error.message : String(error) }, 400),
@@ -124,11 +343,36 @@ export function createStudioFetch(
124
343
  }
125
344
  }
126
345
 
346
+ if (url.pathname === "/api/vault/view") {
347
+ const collected = await inventory();
348
+ const selection = parseVaultSelection(url);
349
+ const companion = resolveCompanion(collected, selection.companionDigest);
350
+ if (!companion) return respond(json({ reason: "no registered companion was selected" }, 400));
351
+ const state = await collectVaultState(vault, companion.path, selection, { tree: "await" });
352
+ return respond(
353
+ new Response(
354
+ await renderVaultView({
355
+ inventory: collected,
356
+ selection,
357
+ companion,
358
+ payload: null,
359
+ error: null,
360
+ collectedAt: null,
361
+ writable,
362
+ vault: state,
363
+ terminal: null,
364
+ }),
365
+ { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
366
+ ),
367
+ );
368
+ }
369
+
127
370
  if (url.pathname === "/api/vault/file") {
128
- const selected = await selectedCompanion(url, inventory);
371
+ const selection = parseVaultSelection(url);
372
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
129
373
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
130
374
  try {
131
- return respond(json(await vault.open(selected.path, url.searchParams.get("path") ?? "")));
375
+ return respond(json(await vault.open(selected.path, selection.openPath ?? "")));
132
376
  } catch (error) {
133
377
  return respond(
134
378
  json({ reason: error instanceof Error ? error.message : String(error) }, 400),
@@ -137,10 +381,12 @@ export function createStudioFetch(
137
381
  }
138
382
 
139
383
  if (url.pathname === "/api/vault/events") {
140
- const selected = await selectedCompanion(url, inventory);
384
+ const selection = parseVaultSelection(url);
385
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
141
386
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
142
387
  try {
143
- const opened = await resolveVaultPath(selected.path, url.searchParams.get("path") ?? "");
388
+ const opened = await resolveVaultPath(selected.path, selection.openPath ?? "");
389
+ server?.timeout?.(request, 0);
144
390
  return respond(vaultEvents(vault, selected.path, opened.relative));
145
391
  } catch (error) {
146
392
  return respond(
@@ -150,6 +396,12 @@ export function createStudioFetch(
150
396
  }
151
397
 
152
398
  if (isSave) {
399
+ if (!access.originMatches(request, acceptedOrigin)) {
400
+ return respond(refused("a save must come from Studio's own page", 403));
401
+ }
402
+ if (access.requires("save") && !access.hasToken(request)) {
403
+ return respond(refused(TOKEN_REASON, 401));
404
+ }
153
405
  if (!writable)
154
406
  return respond(json({ reason: "start Studio with --writable to enable saving" }, 403));
155
407
  let body: { companion?: string; path?: string; content?: string; token?: string };
@@ -159,7 +411,7 @@ export function createStudioFetch(
159
411
  return respond(json({ reason: "save requires a JSON body" }, 400));
160
412
  }
161
413
  const selected = await selectedCompanion(
162
- new URL(`${url.origin}/?companion=${encodeURIComponent(body.companion ?? "")}`),
414
+ typeof body.companion === "string" ? body.companion : null,
163
415
  inventory,
164
416
  );
165
417
  if (!selected || typeof body.path !== "string" || typeof body.content !== "string") {
@@ -182,21 +434,35 @@ export function createStudioFetch(
182
434
 
183
435
  if (url.pathname !== "/") return respond(new Response("not found", { status: 404 }));
184
436
 
185
- return respond(
186
- html(await render(await collectStudioPage(url, inventory, snapshots, vault, writable))),
187
- );
437
+ const page = await collectStudioPage(url, inventory, snapshots, vault, writable);
438
+ if (terminal) {
439
+ const target = await effectiveLaunchCompanion(
440
+ page.inventory,
441
+ launchCompanion,
442
+ page.selection.companionDigest,
443
+ );
444
+ page.terminal = {
445
+ pinned: launchCompanion !== null,
446
+ target: target ? { path: target.path, digest: companionDigest(target.path) } : null,
447
+ agents: target ? await agentsFor(target.path) : [],
448
+ };
449
+ }
450
+ return respond(html(await render(page)));
188
451
  };
189
452
 
190
- handler.close = () => vault.stop();
453
+ handler.terminal = terminal;
454
+ handler.close = async () => {
455
+ vault.stop();
456
+ await terminal?.stopAll();
457
+ };
191
458
  return handler;
192
459
  }
193
460
 
194
461
  async function selectedCompanion(
195
- url: URL,
462
+ digest: string | null,
196
463
  collectInventory: () => Promise<StudioInventory>,
197
464
  ): Promise<ReturnType<typeof resolveCompanion>> {
198
- const inventory = await collectInventory();
199
- return resolveCompanion(inventory, parseStudioSelection(url).companionDigest);
465
+ return resolveCompanion(await collectInventory(), digest);
200
466
  }
201
467
 
202
468
  function vaultEvents(vault: VaultManager, companionPath: string, requestedPath: string): Response {
@@ -237,7 +503,7 @@ async function collectStudioPage(
237
503
  vault: VaultManager,
238
504
  writable: boolean,
239
505
  ): Promise<StudioPage> {
240
- const selection = parseStudioSelection(url);
506
+ const selection = parse(url);
241
507
  const inventory = await collectInventory();
242
508
  const companion = resolveCompanion(inventory, selection.companionDigest);
243
509
  const page: StudioPage = {
@@ -249,6 +515,7 @@ async function collectStudioPage(
249
515
  collectedAt: null,
250
516
  writable,
251
517
  vault: null,
518
+ terminal: null,
252
519
  };
253
520
 
254
521
  if (!companion) {
@@ -257,38 +524,10 @@ async function collectStudioPage(
257
524
  }
258
525
 
259
526
  if (selection.view === "vault") {
260
- try {
261
- const tree = await vault.tree(companion.path, selection.refresh);
262
- let open = null;
263
- let refusal: string | null = null;
264
- if (selection.openPath) {
265
- try {
266
- open = await vault.open(companion.path, selection.openPath);
267
- } catch (error) {
268
- refusal = error instanceof Error ? error.message : String(error);
269
- }
270
- }
271
- return {
272
- ...page,
273
- vault: {
274
- tree: tree.tree,
275
- open,
276
- refusal,
277
- incoming: null,
278
- overwritten: null,
279
- watching: tree.watching,
280
- warning: tree.warning,
281
- },
282
- };
283
- } catch (error) {
284
- return {
285
- ...page,
286
- error: {
287
- companionPath: companion.path,
288
- reason: error instanceof Error ? error.message : String(error),
289
- },
290
- };
291
- }
527
+ return {
528
+ ...page,
529
+ vault: await collectVaultState(vault, companion.path, selection, { tree: "prefetch" }),
530
+ };
292
531
  }
293
532
 
294
533
  vault.deactivate();
@@ -335,8 +574,59 @@ export function startStudioServer(
335
574
  const serve = deps.serve ?? bunServe;
336
575
  const port = options.port ?? 0;
337
576
  const hostname = options.hostname ?? STUDIO_HOSTNAME;
338
- const fetchHandler = createStudioFetch(deps, { writable: options.writable });
339
- const server = serve({ port, hostname, fetch: fetchHandler });
577
+ let boundPort = port;
578
+ const access = createStudioAccess({
579
+ invocation: options.invocation ?? "interactive",
580
+ terminal: options.terminal === true,
581
+ hostname,
582
+ port: () => boundPort,
583
+ allowedHosts: options.allowedHosts,
584
+ publicOrigin: options.publicOrigin,
585
+ token: options.token,
586
+ });
587
+ const fetchHandler = createStudioFetch(deps, {
588
+ writable: options.writable,
589
+ access,
590
+ terminal: options.terminal === true,
591
+ detachMinutes: options.detachMinutes,
592
+ launchCompanion: options.launchCompanion,
593
+ noGit: options.noGit,
594
+ });
595
+ const registry = fetchHandler.terminal;
596
+ const server = serve({
597
+ port,
598
+ hostname,
599
+ idleTimeout: options.idleTimeout ?? STUDIO_IDLE_TIMEOUT_SECONDS,
600
+ fetch: fetchHandler,
601
+ ...(registry
602
+ ? {
603
+ websocket: {
604
+ open(ws) {
605
+ ws.data.connection = registry.connect({
606
+ send: (data) => void ws.send(data),
607
+ bufferedAmount: () => ws.getBufferedAmount(),
608
+ ping: () => ws.ping(),
609
+ close: (code, reason) => ws.close(code, reason),
610
+ });
611
+ },
612
+ message(ws, message) {
613
+ void ws.data.connection?.message(message);
614
+ },
615
+ close(ws) {
616
+ ws.data.connection?.closed();
617
+ },
618
+ pong(ws) {
619
+ ws.data.connection?.pong();
620
+ },
621
+ idleTimeout: 0,
622
+ sendPings: false,
623
+ backpressureLimit: 2 * 1024 * 1024,
624
+ maxPayloadLength: 256 * 1024,
625
+ },
626
+ }
627
+ : {}),
628
+ });
629
+ boundPort = server.port;
340
630
  const urlHostname =
341
631
  hostname === STUDIO_HOSTNAME || hostname === "localhost" || hostname === "::1"
342
632
  ? "localhost"
@@ -346,10 +636,11 @@ export function startStudioServer(
346
636
 
347
637
  return {
348
638
  url: `http://${urlHostname}:${server.port}`,
639
+ address: studioAddress(hostname, server.port, options.token ? null : access.token),
349
640
  port: server.port,
350
641
  hostname,
351
642
  stop: async () => {
352
- fetchHandler.close();
643
+ await fetchHandler.close();
353
644
  await server.stop(true);
354
645
  },
355
646
  };
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+
4
+ /** Route prefix for the terminal client; served only when the terminal is enabled. */
5
+ export const TERMINAL_ASSET_PREFIX = "/studio/terminal/";
6
+
7
+ const ASSETS: Record<string, { module: string; type: string }> = {
8
+ "xterm.js": { module: "@xterm/xterm/lib/xterm.js", type: "text/javascript" },
9
+ "xterm.css": { module: "@xterm/xterm/css/xterm.css", type: "text/css" },
10
+ "addon-fit.js": { module: "@xterm/addon-fit/lib/addon-fit.js", type: "text/javascript" },
11
+ "addon-unicode11.js": {
12
+ module: "@xterm/addon-unicode11/lib/addon-unicode11.js",
13
+ type: "text/javascript",
14
+ },
15
+ "addon-web-links.js": {
16
+ module: "@xterm/addon-web-links/lib/addon-web-links.js",
17
+ type: "text/javascript",
18
+ },
19
+ "addon-clipboard.js": {
20
+ module: "@xterm/addon-clipboard/lib/addon-clipboard.js",
21
+ type: "text/javascript",
22
+ },
23
+ };
24
+
25
+ export const TERMINAL_SCRIPTS = [
26
+ "xterm.js",
27
+ "addon-fit.js",
28
+ "addon-unicode11.js",
29
+ "addon-web-links.js",
30
+ "addon-clipboard.js",
31
+ ].map((name) => `${TERMINAL_ASSET_PREFIX}${name}`);
32
+
33
+ export const TERMINAL_STYLESHEET = `${TERMINAL_ASSET_PREFIX}xterm.css`;
34
+
35
+ const require = createRequire(import.meta.url);
36
+
37
+ /** `null` for any name outside the fixed set, so the route never maps a request to a path. */
38
+ export async function terminalAsset(pathname: string): Promise<Response | null> {
39
+ if (!pathname.startsWith(TERMINAL_ASSET_PREFIX)) return null;
40
+ const asset = ASSETS[pathname.slice(TERMINAL_ASSET_PREFIX.length)];
41
+ if (!asset) return null;
42
+ const body = await fs.readFile(require.resolve(asset.module));
43
+ return new Response(body, {
44
+ headers: {
45
+ "content-type": `${asset.type}; charset=utf-8`,
46
+ "cache-control": "no-cache",
47
+ "x-content-type-options": "nosniff",
48
+ },
49
+ });
50
+ }