@uniqbit/mate-core 0.18.1-canary.4 → 0.18.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 +1 -1
- package/src/cli/commands/report/delivery.ts +50 -0
- package/src/cli/commands/report/index.ts +22 -4
- package/src/cli/commands/studio/access.ts +6 -0
- package/src/cli/commands/studio/actions.ts +87 -0
- package/src/cli/commands/studio/reports.ts +176 -0
- package/src/cli/commands/studio/routes.ts +12 -0
- package/src/cli/commands/studio/selection.ts +2 -1
- package/src/cli/commands/studio/server.ts +276 -4
- package/src/cli/commands/studio/terminal-launch.ts +38 -3
- package/src/cli/commands/studio/terminal.ts +48 -4
- package/src/cli/commands/studio/vault.ts +55 -8
- package/src/cli/commands/studio/views/client.ts +263 -27
- package/src/cli/commands/studio/views/document.tsx +168 -20
- package/src/cli/commands/studio/views/model.ts +2 -0
- package/src/cli/commands/studio/views/reports/index.tsx +49 -0
- package/src/cli/commands/studio/views/specs/index.tsx +65 -9
- package/src/cli/commands/studio/views/styles.ts +26 -6
- package/src/cli/commands/studio/views/terminal.tsx +62 -4
- package/src/cli/commands/studio/views/vault/state.ts +1 -0
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +11 -5
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +11 -5
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +12 -12
- package/src/tools/setup/mate.ts +1 -1
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createStudioAccess,
|
|
3
3
|
exchangeStudioToken,
|
|
4
|
+
normalizeOrigin,
|
|
5
|
+
readBearer,
|
|
4
6
|
studioAddress,
|
|
5
7
|
type StudioAccess,
|
|
6
8
|
type StudioInvocation,
|
|
7
9
|
} from "./access";
|
|
8
10
|
import { collectStudioInventory, type StudioInventory } from "./inventory";
|
|
9
11
|
import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
|
|
10
|
-
import {
|
|
12
|
+
import { createReportStore, isReportId, REPORT_LIMITS, type ReportStore } from "./reports";
|
|
11
13
|
import {
|
|
14
|
+
REPORTS_ROUTE,
|
|
15
|
+
REPORT_VIEW_PREFIX,
|
|
16
|
+
STUDIO_HOSTNAME,
|
|
17
|
+
VAULT_CHANGES_ROUTE,
|
|
18
|
+
VAULT_DIR_ROUTE,
|
|
19
|
+
VAULT_FILTER_ROUTE,
|
|
20
|
+
} from "./routes";
|
|
21
|
+
import {
|
|
22
|
+
COMPANION_DIGEST_PATTERN,
|
|
23
|
+
COMPANION_PARAM,
|
|
12
24
|
companionDigest,
|
|
13
25
|
parse,
|
|
14
26
|
parseVaultSelection,
|
|
@@ -92,6 +104,7 @@ export interface StudioServerDeps {
|
|
|
92
104
|
serve?: (options: StudioServeOptions) => StudioBoundServer;
|
|
93
105
|
snapshots?: StudioSnapshotCache;
|
|
94
106
|
vault?: VaultManager;
|
|
107
|
+
reports?: ReportStore;
|
|
95
108
|
terminal?: Partial<TerminalRegistryOptions>;
|
|
96
109
|
launchableAgents?: typeof launchableAgents;
|
|
97
110
|
}
|
|
@@ -129,6 +142,16 @@ async function renderVaultView(page: StudioPage): Promise<string> {
|
|
|
129
142
|
return views.renderVaultView(page);
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
async function renderVaultChildren(page: StudioPage): Promise<string | null> {
|
|
146
|
+
const views = await import("./views/document");
|
|
147
|
+
return views.renderVaultChildren(page);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function renderVaultMatches(page: StudioPage, query: string): Promise<string> {
|
|
151
|
+
const views = await import("./views/document");
|
|
152
|
+
return views.renderVaultMatches(page, query);
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
/** Keep the CLI entry free of eager Studio view imports. */
|
|
133
156
|
async function collectVaultState(
|
|
134
157
|
vault: VaultManager,
|
|
@@ -166,6 +189,57 @@ function refused(reason: string, status: number): Response {
|
|
|
166
189
|
});
|
|
167
190
|
}
|
|
168
191
|
|
|
192
|
+
/** Sent with every hosted report: opaque origin, no connections, no Studio cookie or storage. */
|
|
193
|
+
const REPORT_CSP = [
|
|
194
|
+
"sandbox allow-scripts allow-modals",
|
|
195
|
+
"default-src 'none'",
|
|
196
|
+
"script-src 'unsafe-inline'",
|
|
197
|
+
"style-src 'unsafe-inline'",
|
|
198
|
+
"img-src data:",
|
|
199
|
+
"font-src data:",
|
|
200
|
+
"connect-src 'none'",
|
|
201
|
+
].join("; ");
|
|
202
|
+
|
|
203
|
+
/** Page-facing report announcement; carries no path and no content. */
|
|
204
|
+
interface ReportPublishedEvent {
|
|
205
|
+
type: "report-published";
|
|
206
|
+
/** Digest of the companion the report belongs to. */
|
|
207
|
+
companion: string;
|
|
208
|
+
id: string;
|
|
209
|
+
title: string;
|
|
210
|
+
createdAt: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The body as text, or `null` over `limit`. An oversized body is drained unparsed
|
|
215
|
+
* and unbuffered (up to a hard bound): answering before the client finished
|
|
216
|
+
* sending wedges its keep-alive connection.
|
|
217
|
+
*/
|
|
218
|
+
async function readCapped(request: Request, limit: number): Promise<string | null> {
|
|
219
|
+
if (!request.body) return "";
|
|
220
|
+
const declared = Number(request.headers.get("content-length"));
|
|
221
|
+
const reader = request.body.getReader();
|
|
222
|
+
const chunks: Uint8Array[] = [];
|
|
223
|
+
let size = 0;
|
|
224
|
+
let over = Number.isFinite(declared) && declared > limit;
|
|
225
|
+
for (;;) {
|
|
226
|
+
const { done, value } = await reader.read();
|
|
227
|
+
if (done) break;
|
|
228
|
+
size += value.byteLength;
|
|
229
|
+
if (size > limit) over = true;
|
|
230
|
+
if (over) {
|
|
231
|
+
chunks.length = 0;
|
|
232
|
+
if (size > limit * 4) {
|
|
233
|
+
await reader.cancel();
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
chunks.push(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return over ? null : Buffer.concat(chunks).toString("utf8");
|
|
241
|
+
}
|
|
242
|
+
|
|
169
243
|
const TOKEN_REASON =
|
|
170
244
|
"Studio's access token is required; open the address Studio printed when it started";
|
|
171
245
|
|
|
@@ -187,6 +261,10 @@ export interface StudioFetchOptions extends Pick<
|
|
|
187
261
|
"writable" | "launchCompanion" | "noGit" | "detachMinutes"
|
|
188
262
|
> {
|
|
189
263
|
access?: StudioAccess;
|
|
264
|
+
/** Loopback origin the terminal's processes publish to; read at spawn. */
|
|
265
|
+
reportOrigin?: () => string | null;
|
|
266
|
+
/** The only origin an absolute report URL is built from. */
|
|
267
|
+
publicOrigin?: string | null;
|
|
190
268
|
/** Enables the terminal; the invocation sets its default detach window. */
|
|
191
269
|
terminal?: boolean;
|
|
192
270
|
}
|
|
@@ -219,6 +297,10 @@ export function createStudioFetch(
|
|
|
219
297
|
hostname: STUDIO_HOSTNAME,
|
|
220
298
|
port: () => 80,
|
|
221
299
|
});
|
|
300
|
+
const reports = deps.reports ?? createReportStore();
|
|
301
|
+
void reports.cleanup().catch(() => {});
|
|
302
|
+
const publicOrigin = options.publicOrigin ? normalizeOrigin(options.publicOrigin) : null;
|
|
303
|
+
const reportListeners = new Map<string, Set<(event: ReportPublishedEvent) => void>>();
|
|
222
304
|
const launchCompanion = options.launchCompanion ?? null;
|
|
223
305
|
const agentsFor = deps.launchableAgents ?? launchableAgents;
|
|
224
306
|
const terminal = options.terminal
|
|
@@ -231,10 +313,55 @@ export function createStudioFetch(
|
|
|
231
313
|
}),
|
|
232
314
|
mateCommand: process.argv.slice(0, 2),
|
|
233
315
|
noGit: options.noGit === true,
|
|
316
|
+
reportUrl: options.reportOrigin,
|
|
234
317
|
...deps.terminal,
|
|
235
318
|
})
|
|
236
319
|
: null;
|
|
237
320
|
|
|
321
|
+
/** Stores a report for the live terminal session holding the bearer credential. */
|
|
322
|
+
const publishReport = async (request: Request): Promise<Response> => {
|
|
323
|
+
const token = readBearer(request);
|
|
324
|
+
const companionPath = token ? (terminal?.reportCompanion(token) ?? null) : null;
|
|
325
|
+
if (!companionPath) return refused("a live Studio session's credential is required", 401);
|
|
326
|
+
const raw = await readCapped(request, REPORT_LIMITS.uploadBytes);
|
|
327
|
+
if (raw === null) return json({ reason: "the report exceeds the size limit" }, 413);
|
|
328
|
+
let body: { title?: unknown; html?: unknown };
|
|
329
|
+
try {
|
|
330
|
+
body = JSON.parse(raw) as typeof body;
|
|
331
|
+
} catch {
|
|
332
|
+
return json({ reason: "publishing requires a JSON body" }, 400);
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
!body ||
|
|
336
|
+
typeof body !== "object" ||
|
|
337
|
+
typeof body.title !== "string" ||
|
|
338
|
+
typeof body.html !== "string" ||
|
|
339
|
+
!body.html
|
|
340
|
+
) {
|
|
341
|
+
return json({ reason: "a report needs a string title and html" }, 400);
|
|
342
|
+
}
|
|
343
|
+
const digest = companionDigest(companionPath);
|
|
344
|
+
const stored = await reports.publish(digest, {
|
|
345
|
+
title: body.title.slice(0, 200),
|
|
346
|
+
html: body.html,
|
|
347
|
+
});
|
|
348
|
+
for (const notify of reportListeners.get(digest) ?? []) {
|
|
349
|
+
notify({
|
|
350
|
+
type: "report-published",
|
|
351
|
+
companion: digest,
|
|
352
|
+
id: stored.id,
|
|
353
|
+
title: stored.title,
|
|
354
|
+
createdAt: stored.createdAt,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const path = `${REPORT_VIEW_PREFIX}${stored.id}`;
|
|
358
|
+
return json({
|
|
359
|
+
id: stored.id,
|
|
360
|
+
path,
|
|
361
|
+
...(publicOrigin ? { url: `${publicOrigin}${path}` } : {}),
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
|
|
238
365
|
const handler = async (
|
|
239
366
|
request: Request,
|
|
240
367
|
server?: StudioUpgrader,
|
|
@@ -246,6 +373,10 @@ export function createStudioFetch(
|
|
|
246
373
|
const exchanged = exchangeStudioToken(request, access, acceptedOrigin);
|
|
247
374
|
if (exchanged) return exchanged;
|
|
248
375
|
|
|
376
|
+
if (url.pathname === REPORTS_ROUTE && request.method === "POST") {
|
|
377
|
+
return publishReport(request);
|
|
378
|
+
}
|
|
379
|
+
|
|
249
380
|
const isSave = url.pathname === "/api/vault/save";
|
|
250
381
|
const isEnd = url.pathname === "/api/terminal/sessions/end";
|
|
251
382
|
const isPost = isSave || isEnd;
|
|
@@ -275,6 +406,31 @@ export function createStudioFetch(
|
|
|
275
406
|
return respond(refused(TOKEN_REASON, 401));
|
|
276
407
|
}
|
|
277
408
|
|
|
409
|
+
if (url.pathname === REPORTS_ROUTE) {
|
|
410
|
+
const digest = url.searchParams.get(COMPANION_PARAM) ?? "";
|
|
411
|
+
if (!COMPANION_DIGEST_PATTERN.test(digest)) {
|
|
412
|
+
return respond(json({ reason: "no registered companion was selected" }, 400));
|
|
413
|
+
}
|
|
414
|
+
return respond(json({ reports: await reports.list(digest) }));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (url.pathname.startsWith(REPORT_VIEW_PREFIX)) {
|
|
418
|
+
const id = url.pathname.slice(REPORT_VIEW_PREFIX.length);
|
|
419
|
+
const stored = isReportId(id) ? await reports.read(id) : null;
|
|
420
|
+
if (stored === null) return respond(new Response("not found", { status: 404 }));
|
|
421
|
+
return respond(
|
|
422
|
+
new Response(stored, {
|
|
423
|
+
headers: {
|
|
424
|
+
"content-type": "text/html; charset=utf-8",
|
|
425
|
+
"cache-control": "no-store",
|
|
426
|
+
"content-security-policy": REPORT_CSP,
|
|
427
|
+
"x-content-type-options": "nosniff",
|
|
428
|
+
"referrer-policy": "no-referrer",
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
278
434
|
if (url.pathname.startsWith("/studio/terminal/") && terminal) {
|
|
279
435
|
const asset = await terminalAsset(url.pathname);
|
|
280
436
|
if (asset) return respond(asset);
|
|
@@ -367,6 +523,74 @@ export function createStudioFetch(
|
|
|
367
523
|
);
|
|
368
524
|
}
|
|
369
525
|
|
|
526
|
+
if (url.pathname === VAULT_DIR_ROUTE || url.pathname === VAULT_FILTER_ROUTE) {
|
|
527
|
+
const selection = parseVaultSelection(url);
|
|
528
|
+
const selected = await selectedCompanion(selection.companionDigest, inventory);
|
|
529
|
+
if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
|
|
530
|
+
let listed;
|
|
531
|
+
try {
|
|
532
|
+
listed = await vault.tree(selected.path);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return respond(
|
|
535
|
+
json({ reason: error instanceof Error ? error.message : String(error) }, 400),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const page: StudioPage = {
|
|
539
|
+
inventory: await inventory(),
|
|
540
|
+
selection,
|
|
541
|
+
companion: selected,
|
|
542
|
+
payload: null,
|
|
543
|
+
error: null,
|
|
544
|
+
collectedAt: null,
|
|
545
|
+
writable,
|
|
546
|
+
vault: {
|
|
547
|
+
tree: listed.tree,
|
|
548
|
+
open: null,
|
|
549
|
+
refusal: null,
|
|
550
|
+
incoming: null,
|
|
551
|
+
overwritten: null,
|
|
552
|
+
watching: listed.watching,
|
|
553
|
+
warning: listed.warning,
|
|
554
|
+
generation: listed.generation,
|
|
555
|
+
},
|
|
556
|
+
terminal: null,
|
|
557
|
+
};
|
|
558
|
+
const headers = {
|
|
559
|
+
"content-type": "text/html; charset=utf-8",
|
|
560
|
+
"cache-control": "no-store",
|
|
561
|
+
"x-vault-generation": String(listed.generation),
|
|
562
|
+
};
|
|
563
|
+
if (url.pathname === VAULT_FILTER_ROUTE) {
|
|
564
|
+
const matches = await renderVaultMatches(page, url.searchParams.get("q") ?? "");
|
|
565
|
+
return respond(new Response(matches, { headers }));
|
|
566
|
+
}
|
|
567
|
+
const children = await renderVaultChildren(page);
|
|
568
|
+
if (children === null) {
|
|
569
|
+
return respond(json({ reason: "that folder is not in the listed tree" }, 404));
|
|
570
|
+
}
|
|
571
|
+
return respond(new Response(children, { headers }));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (url.pathname === VAULT_CHANGES_ROUTE) {
|
|
575
|
+
const selection = parseVaultSelection(url);
|
|
576
|
+
const selected = await selectedCompanion(selection.companionDigest, inventory);
|
|
577
|
+
if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
|
|
578
|
+
server?.timeout?.(request, 0);
|
|
579
|
+
const reportsOnly = url.searchParams.get("scope") === "reports";
|
|
580
|
+
return respond(
|
|
581
|
+
changeStream(vault, selected.path, reportsOnly, (notify) => {
|
|
582
|
+
const digest = companionDigest(selected.path);
|
|
583
|
+
const set = reportListeners.get(digest) ?? new Set();
|
|
584
|
+
set.add(notify);
|
|
585
|
+
reportListeners.set(digest, set);
|
|
586
|
+
return () => {
|
|
587
|
+
set.delete(notify);
|
|
588
|
+
if (set.size === 0) reportListeners.delete(digest);
|
|
589
|
+
};
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
370
594
|
if (url.pathname === "/api/vault/file") {
|
|
371
595
|
const selection = parseVaultSelection(url);
|
|
372
596
|
const selected = await selectedCompanion(selection.companionDigest, inventory);
|
|
@@ -465,13 +689,17 @@ async function selectedCompanion(
|
|
|
465
689
|
return resolveCompanion(await collectInventory(), digest);
|
|
466
690
|
}
|
|
467
691
|
|
|
468
|
-
|
|
692
|
+
/** Server-sent events: `subscribe` gets a send function and returns its unsubscribe. */
|
|
693
|
+
function eventStream(
|
|
694
|
+
greeting: string,
|
|
695
|
+
subscribe: (send: (event: unknown) => void) => () => void,
|
|
696
|
+
): Response {
|
|
469
697
|
const encoder = new TextEncoder();
|
|
470
698
|
let unsubscribe = () => {};
|
|
471
699
|
const stream = new ReadableStream<Uint8Array>({
|
|
472
700
|
start(controller) {
|
|
473
|
-
controller.enqueue(encoder.encode(
|
|
474
|
-
unsubscribe =
|
|
701
|
+
controller.enqueue(encoder.encode(`: ${greeting}\n\n`));
|
|
702
|
+
unsubscribe = subscribe((event) => {
|
|
475
703
|
try {
|
|
476
704
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
477
705
|
} catch {
|
|
@@ -492,6 +720,37 @@ function vaultEvents(vault: VaultManager, companionPath: string, requestedPath:
|
|
|
492
720
|
});
|
|
493
721
|
}
|
|
494
722
|
|
|
723
|
+
function vaultEvents(vault: VaultManager, companionPath: string, requestedPath: string): Response {
|
|
724
|
+
return eventStream("studio vault events", (send) =>
|
|
725
|
+
vault.subscribe(companionPath, requestedPath, send),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Typed events for one companion: `vault-tree-changed` (a generation) and
|
|
731
|
+
* `report-published`. No paths and no file content leave through this stream.
|
|
732
|
+
* `reportsOnly` skips the vault subscription, which starts a watcher and listing.
|
|
733
|
+
*/
|
|
734
|
+
function changeStream(
|
|
735
|
+
vault: VaultManager,
|
|
736
|
+
companionPath: string,
|
|
737
|
+
reportsOnly: boolean,
|
|
738
|
+
subscribeReports: (notify: (event: ReportPublishedEvent) => void) => () => void,
|
|
739
|
+
): Response {
|
|
740
|
+
return eventStream("studio changes", (send) => {
|
|
741
|
+
const stopReports = subscribeReports(send);
|
|
742
|
+
const stopTree = reportsOnly
|
|
743
|
+
? () => {}
|
|
744
|
+
: vault.subscribeTree(companionPath, (generation) =>
|
|
745
|
+
send({ type: "vault-tree-changed", generation }),
|
|
746
|
+
);
|
|
747
|
+
return () => {
|
|
748
|
+
stopReports();
|
|
749
|
+
stopTree();
|
|
750
|
+
};
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
495
754
|
/**
|
|
496
755
|
* One rendered document's worth of state. An absent or unresolvable companion
|
|
497
756
|
* is not a failure: the page renders the selector and the server keeps serving.
|
|
@@ -563,6 +822,17 @@ function bunServe(options: StudioServeOptions): StudioBoundServer {
|
|
|
563
822
|
return runtime.serve(options);
|
|
564
823
|
}
|
|
565
824
|
|
|
825
|
+
/** Where a process on this host reaches the server: loopback for wildcard binds. */
|
|
826
|
+
function reportOrigin(hostname: string, port: number): string {
|
|
827
|
+
const wildcard = hostname === "0.0.0.0" || hostname === "::" || hostname === "[::]";
|
|
828
|
+
const host = wildcard
|
|
829
|
+
? "127.0.0.1"
|
|
830
|
+
: hostname.includes(":") && !hostname.startsWith("[")
|
|
831
|
+
? `[${hostname}]`
|
|
832
|
+
: hostname;
|
|
833
|
+
return `http://${host}:${port}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
566
836
|
/**
|
|
567
837
|
* A bind failure propagates: the caller reports it and exits rather than
|
|
568
838
|
* opening a browser at a URL nothing answers.
|
|
@@ -587,6 +857,8 @@ export function startStudioServer(
|
|
|
587
857
|
const fetchHandler = createStudioFetch(deps, {
|
|
588
858
|
writable: options.writable,
|
|
589
859
|
access,
|
|
860
|
+
publicOrigin: options.publicOrigin,
|
|
861
|
+
reportOrigin: () => reportOrigin(hostname, boundPort),
|
|
590
862
|
terminal: options.terminal === true,
|
|
591
863
|
detachMinutes: options.detachMinutes,
|
|
592
864
|
launchCompanion: options.launchCompanion,
|
|
@@ -4,10 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
5
5
|
import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
|
|
6
6
|
import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
|
|
7
|
+
import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
|
|
8
|
+
import { collectSkillInventory, type StudioSkillInventory } from "./mate-inventory";
|
|
9
|
+
import { listSpecs } from "./openspec-cli";
|
|
7
10
|
import { resolveCompanion } from "./selection";
|
|
8
11
|
import {
|
|
9
12
|
agentInstalled,
|
|
10
13
|
TERMINAL_AGENTS,
|
|
14
|
+
type TerminalAction,
|
|
11
15
|
type TerminalAgent,
|
|
12
16
|
type TerminalLaunchResolution,
|
|
13
17
|
} from "./terminal";
|
|
@@ -77,6 +81,14 @@ export interface LaunchResolverOptions {
|
|
|
77
81
|
launchCompanion?: string | null;
|
|
78
82
|
launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
|
|
79
83
|
defaultAgent?: (companionPath: string, agent: TerminalAgent) => Promise<DefaultAgentOutcome>;
|
|
84
|
+
skillInventory?: (companionPath: string) => Promise<StudioSkillInventory>;
|
|
85
|
+
/** Whether the companion has a canonical spec for this capability, read at launch. */
|
|
86
|
+
specExists?: (companionPath: string, capability: string) => Promise<boolean>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function specInCompanion(companionPath: string, capability: string): Promise<boolean> {
|
|
90
|
+
const result = await listSpecs(companionPath);
|
|
91
|
+
return result.ok && (result.value.specs ?? []).some((spec) => spec.id === capability);
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
/** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
|
|
@@ -94,10 +106,16 @@ export async function effectiveLaunchCompanion(
|
|
|
94
106
|
/** Revalidates at launch time; the rendered page and its digest are never trusted. */
|
|
95
107
|
export function createLaunchResolver(
|
|
96
108
|
options: LaunchResolverOptions,
|
|
97
|
-
): (
|
|
109
|
+
): (
|
|
110
|
+
agent: string,
|
|
111
|
+
digest: string | null,
|
|
112
|
+
action?: TerminalAction,
|
|
113
|
+
) => Promise<TerminalLaunchResolution> {
|
|
98
114
|
const agents = options.launchableAgents ?? launchableAgents;
|
|
99
115
|
const defaultAgent = options.defaultAgent ?? defaultAgentFor;
|
|
100
|
-
|
|
116
|
+
const skills = options.skillInventory ?? collectSkillInventory;
|
|
117
|
+
const specExists = options.specExists ?? specInCompanion;
|
|
118
|
+
return async (agent, digest, request) => {
|
|
101
119
|
const companion = await effectiveLaunchCompanion(
|
|
102
120
|
await options.collectInventory(),
|
|
103
121
|
options.launchCompanion,
|
|
@@ -113,9 +131,26 @@ export function createLaunchResolver(
|
|
|
113
131
|
if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
|
|
114
132
|
return { reason: `${agent} is not allowed by this companion or is not installed` };
|
|
115
133
|
}
|
|
116
|
-
|
|
134
|
+
const launch = {
|
|
117
135
|
companionPath: companion.path,
|
|
118
136
|
...(await defaultAgent(companion.path, agent as TerminalAgent)),
|
|
119
137
|
};
|
|
138
|
+
if (!request) return launch;
|
|
139
|
+
|
|
140
|
+
const action = findAction(request.action);
|
|
141
|
+
if (!action) return { reason: "the requested action is not registered" };
|
|
142
|
+
const subject = request.subject;
|
|
143
|
+
if (typeof subject !== "string" || !SUBJECT_PATTERN.test(subject)) {
|
|
144
|
+
return { reason: "the action subject is not a valid spec name" };
|
|
145
|
+
}
|
|
146
|
+
if (!skillInstalledFor(await skills(companion.path), action.skill, agent as TerminalAgent)) {
|
|
147
|
+
return {
|
|
148
|
+
reason: `the ${action.skill} skill is not installed for ${agent} in this companion`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!(await specExists(companion.path, subject))) {
|
|
152
|
+
return { reason: `this companion has no spec named ${subject}` };
|
|
153
|
+
}
|
|
154
|
+
return { ...launch, promptArgs: promptArgs(action, agent as TerminalAgent, subject) };
|
|
120
155
|
};
|
|
121
156
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { tokensEqual } from "./access";
|
|
2
4
|
|
|
3
5
|
export const TERMINAL_AGENTS = ["claude", "opencode"] as const;
|
|
4
6
|
export type TerminalAgent = (typeof TERMINAL_AGENTS)[number];
|
|
@@ -177,11 +179,19 @@ export interface TerminalSessionInfo {
|
|
|
177
179
|
startedAt: number;
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
/** A registered action and its subject; the client names them, the server builds the prompt. */
|
|
183
|
+
export interface TerminalAction {
|
|
184
|
+
action: unknown;
|
|
185
|
+
subject: unknown;
|
|
186
|
+
}
|
|
187
|
+
|
|
180
188
|
export type TerminalLaunchResolution =
|
|
181
189
|
| {
|
|
182
190
|
companionPath: string;
|
|
183
191
|
/** Extra argv entries for the managed launch; set only by companion configuration. */
|
|
184
192
|
agentArgs?: string[];
|
|
193
|
+
/** The registry's first-turn prompt, appended after `agentArgs`; never from the client. */
|
|
194
|
+
promptArgs?: string[];
|
|
185
195
|
/** Written to the terminal before the agent starts. */
|
|
186
196
|
notice?: string;
|
|
187
197
|
}
|
|
@@ -193,10 +203,13 @@ export interface TerminalRegistryOptions {
|
|
|
193
203
|
resolveLaunch: (
|
|
194
204
|
agent: string,
|
|
195
205
|
companionDigest: string | null,
|
|
206
|
+
action?: TerminalAction,
|
|
196
207
|
) => Promise<TerminalLaunchResolution>;
|
|
197
208
|
/** The Mate invocation prefix, e.g. `[bun, cli.mjs]`. */
|
|
198
209
|
mateCommand: string[];
|
|
199
210
|
noGit?: boolean;
|
|
211
|
+
/** Loopback origin of this server, read at spawn: the port is known only after bind. */
|
|
212
|
+
reportUrl?: () => string | null;
|
|
200
213
|
env?: NodeJS.ProcessEnv;
|
|
201
214
|
spawn?: TerminalSpawn;
|
|
202
215
|
signalGroup?: (pid: number, signal: NodeJS.Signals) => void;
|
|
@@ -209,6 +222,8 @@ interface Session {
|
|
|
209
222
|
agent: TerminalAgent;
|
|
210
223
|
companionPath: string;
|
|
211
224
|
startedAt: number;
|
|
225
|
+
/** In memory only; valid while the session is registered. */
|
|
226
|
+
reportToken: string;
|
|
212
227
|
process: TerminalProcess;
|
|
213
228
|
ring: RingBuffer;
|
|
214
229
|
viewer: TerminalViewer | null;
|
|
@@ -273,6 +288,15 @@ export class TerminalRegistry {
|
|
|
273
288
|
}));
|
|
274
289
|
}
|
|
275
290
|
|
|
291
|
+
/** The companion of the live session holding this publish credential, else `null`. */
|
|
292
|
+
reportCompanion(token: string): string | null {
|
|
293
|
+
let found: string | null = null;
|
|
294
|
+
for (const session of this.sessions.values()) {
|
|
295
|
+
if (!session.ending && tokensEqual(token, session.reportToken)) found = session.companionPath;
|
|
296
|
+
}
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
|
|
276
300
|
connect(viewer: TerminalViewer): TerminalConnection {
|
|
277
301
|
const connection: Connection = { viewer, lastPong: this.now(), session: null };
|
|
278
302
|
this.connections.add(connection);
|
|
@@ -370,7 +394,13 @@ export class TerminalRegistry {
|
|
|
370
394
|
const viewer = connection.viewer;
|
|
371
395
|
if (this.stopping) return send(viewer, { type: "error", reason: "Studio is stopping" });
|
|
372
396
|
if (connection.session) {
|
|
373
|
-
return send(viewer, {
|
|
397
|
+
return send(viewer, {
|
|
398
|
+
type: "error",
|
|
399
|
+
reason:
|
|
400
|
+
message.action === undefined && message.subject === undefined
|
|
401
|
+
? "this connection already views a session"
|
|
402
|
+
: "end the current session first",
|
|
403
|
+
});
|
|
374
404
|
}
|
|
375
405
|
const size = this.dimensions(message);
|
|
376
406
|
if (!size) return send(viewer, { type: "error", reason: "terminal size out of range" });
|
|
@@ -379,7 +409,11 @@ export class TerminalRegistry {
|
|
|
379
409
|
}
|
|
380
410
|
const agent = message.agent as TerminalAgent;
|
|
381
411
|
const digest = typeof message.companion === "string" ? message.companion : null;
|
|
382
|
-
const
|
|
412
|
+
const action =
|
|
413
|
+
message.action === undefined && message.subject === undefined
|
|
414
|
+
? undefined
|
|
415
|
+
: { action: message.action, subject: message.subject };
|
|
416
|
+
const resolved = await this.options.resolveLaunch(agent, digest, action);
|
|
383
417
|
if ("reason" in resolved) return send(viewer, { type: "error", reason: resolved.reason });
|
|
384
418
|
if (connection.session || !this.connections.has(connection)) return;
|
|
385
419
|
|
|
@@ -404,7 +438,7 @@ export class TerminalRegistry {
|
|
|
404
438
|
agent: TerminalAgent,
|
|
405
439
|
companionPath: string,
|
|
406
440
|
size: { cols: number; rows: number },
|
|
407
|
-
extras: { agentArgs?: string[]; notice?: string } = {},
|
|
441
|
+
extras: { agentArgs?: string[]; promptArgs?: string[]; notice?: string } = {},
|
|
408
442
|
): Session {
|
|
409
443
|
const env: Record<string, string> = {};
|
|
410
444
|
for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
|
|
@@ -413,6 +447,14 @@ export class TerminalRegistry {
|
|
|
413
447
|
delete env.MATE_REPO_PATH;
|
|
414
448
|
delete env.MATE_REPO_ID;
|
|
415
449
|
delete env.MATE_STUDIO_TOKEN;
|
|
450
|
+
delete env.MATE_STUDIO_REPORT_URL;
|
|
451
|
+
delete env.MATE_STUDIO_REPORT_TOKEN;
|
|
452
|
+
const reportToken = randomBytes(32).toString("base64url");
|
|
453
|
+
const reportUrl = this.options.reportUrl?.() ?? null;
|
|
454
|
+
if (reportUrl) {
|
|
455
|
+
env.MATE_STUDIO_REPORT_URL = reportUrl;
|
|
456
|
+
env.MATE_STUDIO_REPORT_TOKEN = reportToken;
|
|
457
|
+
}
|
|
416
458
|
env.MATE_ARTIFACT_PATH = companionPath;
|
|
417
459
|
env.TERM = "xterm-256color";
|
|
418
460
|
const argv = [
|
|
@@ -423,6 +465,7 @@ export class TerminalRegistry {
|
|
|
423
465
|
"--yes",
|
|
424
466
|
...(this.options.noGit ? ["--no-git"] : []),
|
|
425
467
|
...(extras.agentArgs ?? []),
|
|
468
|
+
...(extras.promptArgs ?? []),
|
|
426
469
|
];
|
|
427
470
|
|
|
428
471
|
const session = {
|
|
@@ -430,6 +473,7 @@ export class TerminalRegistry {
|
|
|
430
473
|
agent,
|
|
431
474
|
companionPath,
|
|
432
475
|
startedAt: this.now(),
|
|
476
|
+
reportToken,
|
|
433
477
|
ring: new RingBuffer(this.limits.ringBytes),
|
|
434
478
|
viewer: null,
|
|
435
479
|
detachedAt: null,
|