@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.
@@ -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,
@@ -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
- ): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
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
- return async (agent, digest) => {
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
- return {
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, { 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
+ });
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 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);
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,