@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.
- package/package.json +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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) && !(
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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.
|
|
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
|
-
|
|
462
|
+
digest: string | null,
|
|
196
463
|
collectInventory: () => Promise<StudioInventory>,
|
|
197
464
|
): Promise<ReturnType<typeof resolveCompanion>> {
|
|
198
|
-
|
|
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 =
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
339
|
-
const
|
|
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
|
+
}
|