@uniqbit/mate-core 0.18.1-canary.2 → 0.18.1-canary.5

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/doctor.ts +8 -3
  3. package/src/cli/commands/install.ts +2 -18
  4. package/src/cli/commands/report/delivery.ts +50 -0
  5. package/src/cli/commands/report/index.ts +22 -4
  6. package/src/cli/commands/studio/access.ts +6 -0
  7. package/src/cli/commands/studio/actions.ts +87 -0
  8. package/src/cli/commands/studio/reports.ts +176 -0
  9. package/src/cli/commands/studio/routes.ts +12 -0
  10. package/src/cli/commands/studio/selection.ts +2 -1
  11. package/src/cli/commands/studio/server.ts +276 -4
  12. package/src/cli/commands/studio/terminal-launch.ts +88 -3
  13. package/src/cli/commands/studio/terminal.ts +60 -5
  14. package/src/cli/commands/studio/vault.ts +55 -8
  15. package/src/cli/commands/studio/views/client.ts +263 -27
  16. package/src/cli/commands/studio/views/document.tsx +168 -20
  17. package/src/cli/commands/studio/views/model.ts +2 -0
  18. package/src/cli/commands/studio/views/reports/index.tsx +49 -0
  19. package/src/cli/commands/studio/views/specs/index.tsx +65 -9
  20. package/src/cli/commands/studio/views/styles.ts +26 -6
  21. package/src/cli/commands/studio/views/terminal.tsx +62 -4
  22. package/src/cli/commands/studio/views/vault/state.ts +1 -0
  23. package/src/lib/orchestrator/types.ts +6 -0
  24. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +11 -5
  25. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +11 -5
  26. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +12 -12
  27. package/src/tools/setup/dynamic-plugins/frozen.ts +3 -24
  28. package/src/tools/setup/dynamic-plugins/loader.ts +0 -16
  29. package/src/tools/setup/dynamic-plugins/verify.ts +18 -22
  30. package/src/tools/setup/mate.ts +1 -1
  31. package/src/tools/setup/dynamic-plugins/policy.ts +0 -55
  32. package/src/tools/setup/dynamic-plugins/staging.ts +0 -55
@@ -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 { STUDIO_HOSTNAME } from "./routes";
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
- function vaultEvents(vault: VaultManager, companionPath: string, requestedPath: string): Response {
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(": studio vault events\n\n"));
474
- unsubscribe = vault.subscribe(companionPath, requestedPath, (event) => {
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,
@@ -1,12 +1,17 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import { FRAMEWORK_NAME } from "../../../framework";
4
5
  import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
5
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";
6
10
  import { resolveCompanion } from "./selection";
7
11
  import {
8
12
  agentInstalled,
9
13
  TERMINAL_AGENTS,
14
+ type TerminalAction,
10
15
  type TerminalAgent,
11
16
  type TerminalLaunchResolution,
12
17
  } from "./terminal";
@@ -26,11 +31,64 @@ export async function launchableAgents(
26
31
  return TERMINAL_AGENTS.filter((agent) => allowed.includes(agent) && installed(agent));
27
32
  }
28
33
 
34
+ /** Lowercase letters, digits and hyphens, so a name can never carry shell syntax or a flag. */
35
+ export const AGENT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
36
+
37
+ const AGENT_DEFINITION_DIRS: Record<TerminalAgent, string> = {
38
+ claude: path.join(".claude", "agents"),
39
+ opencode: path.join(".opencode", "agents"),
40
+ };
41
+
42
+ export interface DefaultAgentOutcome {
43
+ agentArgs?: string[];
44
+ notice?: string;
45
+ }
46
+
47
+ /**
48
+ * The companion's `studio.terminal.agent` for one provider. Never throws and
49
+ * never echoes the configured value into the notice.
50
+ */
51
+ export async function defaultAgentFor(
52
+ companionPath: string,
53
+ agent: TerminalAgent,
54
+ ): Promise<DefaultAgentOutcome> {
55
+ const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
56
+ if (!fs.existsSync(configPath)) return {};
57
+ let name: unknown;
58
+ try {
59
+ name = (await new ConfigStore(configPath).load()).studio?.terminal?.agent;
60
+ } catch {
61
+ return {};
62
+ }
63
+ if (name === undefined || name === null) return {};
64
+ if (typeof name !== "string" || !AGENT_NAME_PATTERN.test(name)) {
65
+ return {
66
+ notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
67
+ };
68
+ }
69
+ const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
70
+ if (!fs.existsSync(definition)) {
71
+ return {
72
+ notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
73
+ };
74
+ }
75
+ return { agentArgs: ["--agent", name] };
76
+ }
77
+
29
78
  export interface LaunchResolverOptions {
30
79
  collectInventory: () => Promise<StudioInventory>;
31
80
  /** Pinned by `serve --companion`; overrides the page selection. */
32
81
  launchCompanion?: string | null;
33
82
  launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
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);
34
92
  }
35
93
 
36
94
  /** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
@@ -48,9 +106,16 @@ export async function effectiveLaunchCompanion(
48
106
  /** Revalidates at launch time; the rendered page and its digest are never trusted. */
49
107
  export function createLaunchResolver(
50
108
  options: LaunchResolverOptions,
51
- ): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
109
+ ): (
110
+ agent: string,
111
+ digest: string | null,
112
+ action?: TerminalAction,
113
+ ) => Promise<TerminalLaunchResolution> {
52
114
  const agents = options.launchableAgents ?? launchableAgents;
53
- return async (agent, digest) => {
115
+ const defaultAgent = options.defaultAgent ?? defaultAgentFor;
116
+ const skills = options.skillInventory ?? collectSkillInventory;
117
+ const specExists = options.specExists ?? specInCompanion;
118
+ return async (agent, digest, request) => {
54
119
  const companion = await effectiveLaunchCompanion(
55
120
  await options.collectInventory(),
56
121
  options.launchCompanion,
@@ -66,6 +131,26 @@ export function createLaunchResolver(
66
131
  if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
67
132
  return { reason: `${agent} is not allowed by this companion or is not installed` };
68
133
  }
69
- return { companionPath: companion.path };
134
+ const launch = {
135
+ companionPath: companion.path,
136
+ ...(await defaultAgent(companion.path, agent as TerminalAgent)),
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) };
70
155
  };
71
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,7 +179,23 @@ export interface TerminalSessionInfo {
177
179
  startedAt: number;
178
180
  }
179
181
 
180
- export type TerminalLaunchResolution = { companionPath: string } | { reason: string };
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
+
188
+ export type TerminalLaunchResolution =
189
+ | {
190
+ companionPath: string;
191
+ /** Extra argv entries for the managed launch; set only by companion configuration. */
192
+ agentArgs?: string[];
193
+ /** The registry's first-turn prompt, appended after `agentArgs`; never from the client. */
194
+ promptArgs?: string[];
195
+ /** Written to the terminal before the agent starts. */
196
+ notice?: string;
197
+ }
198
+ | { reason: string };
181
199
 
182
200
  export interface TerminalRegistryOptions {
183
201
  detachMs: number;
@@ -185,10 +203,13 @@ export interface TerminalRegistryOptions {
185
203
  resolveLaunch: (
186
204
  agent: string,
187
205
  companionDigest: string | null,
206
+ action?: TerminalAction,
188
207
  ) => Promise<TerminalLaunchResolution>;
189
208
  /** The Mate invocation prefix, e.g. `[bun, cli.mjs]`. */
190
209
  mateCommand: string[];
191
210
  noGit?: boolean;
211
+ /** Loopback origin of this server, read at spawn: the port is known only after bind. */
212
+ reportUrl?: () => string | null;
192
213
  env?: NodeJS.ProcessEnv;
193
214
  spawn?: TerminalSpawn;
194
215
  signalGroup?: (pid: number, signal: NodeJS.Signals) => void;
@@ -201,6 +222,8 @@ interface Session {
201
222
  agent: TerminalAgent;
202
223
  companionPath: string;
203
224
  startedAt: number;
225
+ /** In memory only; valid while the session is registered. */
226
+ reportToken: string;
204
227
  process: TerminalProcess;
205
228
  ring: RingBuffer;
206
229
  viewer: TerminalViewer | null;
@@ -265,6 +288,15 @@ export class TerminalRegistry {
265
288
  }));
266
289
  }
267
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
+
268
300
  connect(viewer: TerminalViewer): TerminalConnection {
269
301
  const connection: Connection = { viewer, lastPong: this.now(), session: null };
270
302
  this.connections.add(connection);
@@ -362,7 +394,13 @@ export class TerminalRegistry {
362
394
  const viewer = connection.viewer;
363
395
  if (this.stopping) return send(viewer, { type: "error", reason: "Studio is stopping" });
364
396
  if (connection.session) {
365
- return send(viewer, { type: "error", reason: "this connection already views a session" });
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
+ });
366
404
  }
367
405
  const size = this.dimensions(message);
368
406
  if (!size) return send(viewer, { type: "error", reason: "terminal size out of range" });
@@ -371,7 +409,11 @@ export class TerminalRegistry {
371
409
  }
372
410
  const agent = message.agent as TerminalAgent;
373
411
  const digest = typeof message.companion === "string" ? message.companion : null;
374
- const resolved = await this.options.resolveLaunch(agent, digest);
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);
375
417
  if ("reason" in resolved) return send(viewer, { type: "error", reason: resolved.reason });
376
418
  if (connection.session || !this.connections.has(connection)) return;
377
419
 
@@ -388,7 +430,7 @@ export class TerminalRegistry {
388
430
  void this.finish(detached, { type: "ended", reason: "evicted" });
389
431
  }
390
432
 
391
- const session = this.launch(agent, resolved.companionPath, size);
433
+ const session = this.launch(agent, resolved.companionPath, size, resolved);
392
434
  this.attachTo(connection, session, size);
393
435
  }
394
436
 
@@ -396,6 +438,7 @@ export class TerminalRegistry {
396
438
  agent: TerminalAgent,
397
439
  companionPath: string,
398
440
  size: { cols: number; rows: number },
441
+ extras: { agentArgs?: string[]; promptArgs?: string[]; notice?: string } = {},
399
442
  ): Session {
400
443
  const env: Record<string, string> = {};
401
444
  for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
@@ -404,6 +447,14 @@ export class TerminalRegistry {
404
447
  delete env.MATE_REPO_PATH;
405
448
  delete env.MATE_REPO_ID;
406
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
+ }
407
458
  env.MATE_ARTIFACT_PATH = companionPath;
408
459
  env.TERM = "xterm-256color";
409
460
  const argv = [
@@ -413,6 +464,8 @@ export class TerminalRegistry {
413
464
  "--companion",
414
465
  "--yes",
415
466
  ...(this.options.noGit ? ["--no-git"] : []),
467
+ ...(extras.agentArgs ?? []),
468
+ ...(extras.promptArgs ?? []),
416
469
  ];
417
470
 
418
471
  const session = {
@@ -420,6 +473,7 @@ export class TerminalRegistry {
420
473
  agent,
421
474
  companionPath,
422
475
  startedAt: this.now(),
476
+ reportToken,
423
477
  ring: new RingBuffer(this.limits.ringBytes),
424
478
  viewer: null,
425
479
  detachedAt: null,
@@ -427,6 +481,7 @@ export class TerminalRegistry {
427
481
  ending: null,
428
482
  exited: false,
429
483
  } as Omit<Session, "process"> as Session;
484
+ if (extras.notice) session.ring.push(new TextEncoder().encode(`${extras.notice}\r\n`));
430
485
  session.process = this.spawn({
431
486
  argv,
432
487
  cwd: companionPath,