@uniqbit/mate-core 0.17.0-canary.11 → 0.17.0-canary.13

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 (45) hide show
  1. package/package.json +6 -5
  2. package/src/cli/commands/artifact/pending/index.ts +0 -1
  3. package/src/cli/commands/cap/index-cmd.ts +1 -1
  4. package/src/cli/commands/companion/companion.ts +4 -0
  5. package/src/cli/commands/companion/update.ts +334 -0
  6. package/src/cli/commands/install.ts +59 -5
  7. package/src/cli/commands/plugin/plugin.ts +4 -0
  8. package/src/cli/commands/plugin/verify.ts +25 -0
  9. package/src/cli/commands/studio/access.ts +0 -2
  10. package/src/cli/commands/studio/selection.ts +128 -12
  11. package/src/cli/commands/studio/server.ts +75 -47
  12. package/src/cli/commands/studio/vault.ts +319 -69
  13. package/src/cli/commands/studio/views/client.ts +90 -9
  14. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  15. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  16. package/src/cli/commands/studio/views/document.tsx +403 -109
  17. package/src/cli/commands/studio/views/model.ts +4 -1
  18. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  19. package/src/cli/commands/studio/views/styles.ts +103 -21
  20. package/src/cli/commands/studio/views/terminal.tsx +227 -33
  21. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  22. package/src/cli/commands/update.ts +5 -2
  23. package/src/cli/main.ts +2 -1
  24. package/src/cli/usage.ts +3 -1
  25. package/src/lib/opencode-plugin-package.ts +3 -0
  26. package/src/lib/orchestrator/adapters/opencode.ts +39 -12
  27. package/src/opencode/companion-hooks.ts +131 -153
  28. package/src/opencode/index.ts +12 -6
  29. package/src/opencode/tui.tsx +46 -49
  30. package/src/templates/providers/opencode/opencode.json +6 -4
  31. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +489 -408
  32. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  33. package/src/tools/setup/capabilities/tokensave.ts +96 -9
  34. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  35. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  36. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  37. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  38. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  39. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  40. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  41. package/src/tools/setup/providers/opencode-format.ts +220 -25
  42. package/src/tools/setup/providers/opencode.ts +60 -19
  43. package/src/tools/setup/runtime-documents.ts +8 -4
  44. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  45. package/src/templates/providers/opencode/tui.json +0 -3
@@ -13,24 +13,47 @@ export const STUDIO_VIEWS: readonly StudioView[] = [
13
13
  ];
14
14
 
15
15
  export const COMPANION_PARAM = "companion";
16
- const VIEW_PARAM = "view";
16
+ export const VIEW_PARAM = "view";
17
17
  export const REFRESH_PARAM = "refresh";
18
18
  export const FILE_PARAM = "path";
19
+ export const DIR_PARAM = "dir";
20
+
21
+ /** Every URL parameter Studio names; no other module writes one out. */
22
+ export const STUDIO_PARAMS: readonly string[] = [
23
+ COMPANION_PARAM,
24
+ VIEW_PARAM,
25
+ REFRESH_PARAM,
26
+ FILE_PARAM,
27
+ DIR_PARAM,
28
+ ];
19
29
 
20
30
  /** Filled into a prompt when no change is named, so a prompt is never half-written. */
21
31
  export const CHANGE_PLACEHOLDER = "<change-name>";
22
32
 
23
33
  const DIGEST_LENGTH = 10;
24
34
 
25
- export interface StudioSelection {
35
+ interface SelectionBase {
26
36
  companionDigest: string | null;
27
- view: StudioView;
28
- /**
29
- * One-shot: the refresh control asks for it, and it is never carried forward
30
- * into the URL another control builds. Switching a view is not a refresh.
31
- */
37
+ /** One-shot: only the `refresh` move sets it, and every other move clears it. */
32
38
  refresh: boolean;
39
+ }
40
+
41
+ export interface StudioVaultSelection extends SelectionBase {
42
+ view: "vault";
33
43
  openPath: string | null;
44
+ /** A folder of the listed tree; never looked up on disk. */
45
+ openDir: string | null;
46
+ }
47
+
48
+ export interface StudioPlainSelection extends SelectionBase {
49
+ view: Exclude<StudioView, "vault">;
50
+ }
51
+
52
+ export type StudioSelection = StudioVaultSelection | StudioPlainSelection;
53
+
54
+ export interface SelectionField {
55
+ name: string;
56
+ value: string;
34
57
  }
35
58
 
36
59
  /**
@@ -60,11 +83,104 @@ function readView(value: string | null): StudioView {
60
83
  return STUDIO_VIEWS.includes(value as StudioView) ? (value as StudioView) : "dashboard";
61
84
  }
62
85
 
63
- export function parseStudioSelection(url: URL): StudioSelection {
86
+ function readText(value: string | null): string | null {
87
+ return value?.trim() || null;
88
+ }
89
+
90
+ /** An empty or malformed digest selects no companion, as an unmatched one already does. */
91
+ function readDigest(value: string | null): string | null {
92
+ const digest = readText(value);
93
+ return digest && COMPANION_DIGEST_PATTERN.test(digest) ? digest : null;
94
+ }
95
+
96
+ function readBase(params: URLSearchParams): SelectionBase {
97
+ return {
98
+ companionDigest: readDigest(params.get(COMPANION_PARAM)),
99
+ refresh: params.get(REFRESH_PARAM) === "1",
100
+ };
101
+ }
102
+
103
+ function readVault(params: URLSearchParams): StudioVaultSelection {
104
+ return {
105
+ ...readBase(params),
106
+ view: "vault",
107
+ openPath: readText(params.get(FILE_PARAM)),
108
+ openDir: readText(params.get(DIR_PARAM)),
109
+ };
110
+ }
111
+
112
+ /** Forgiving: parameters the named view does not have are ignored. */
113
+ export function parse(url: URL): StudioSelection {
114
+ const view = readView(url.searchParams.get(VIEW_PARAM));
115
+ if (view === "vault") return readVault(url.searchParams);
116
+ return { ...readBase(url.searchParams), view };
117
+ }
118
+
119
+ /** For the `/api/vault/*` routes, which imply the vault view whatever `view` says. */
120
+ export function parseVaultSelection(url: URL): StudioVaultSelection {
121
+ return readVault(url.searchParams);
122
+ }
123
+
124
+ /** Drops the vault's target. */
125
+ export function switchView(selection: StudioSelection, view: StudioView): StudioSelection {
126
+ const base = { companionDigest: selection.companionDigest, refresh: false };
127
+ if (view === "vault") return { ...base, view, openPath: null, openDir: null };
128
+ return { ...base, view };
129
+ }
130
+
131
+ /** Keeps the view and drops the vault's target: it belongs to the companion being left. */
132
+ export function switchCompanion(
133
+ selection: StudioSelection,
134
+ digest: string | null,
135
+ ): StudioSelection {
136
+ return { ...switchView(selection, selection.view), companionDigest: digest };
137
+ }
138
+
139
+ export function openFile(selection: StudioSelection, path: string): StudioVaultSelection {
64
140
  return {
65
- companionDigest: url.searchParams.get(COMPANION_PARAM)?.trim() || null,
66
- view: readView(url.searchParams.get(VIEW_PARAM)),
67
- refresh: url.searchParams.get(REFRESH_PARAM) === "1",
68
- openPath: url.searchParams.get(FILE_PARAM)?.trim() || null,
141
+ companionDigest: selection.companionDigest,
142
+ refresh: false,
143
+ view: "vault",
144
+ openPath: path,
145
+ openDir: null,
69
146
  };
70
147
  }
148
+
149
+ /** `null` opens the companion root. */
150
+ export function openFolder(selection: StudioSelection, dir: string | null): StudioVaultSelection {
151
+ return {
152
+ companionDigest: selection.companionDigest,
153
+ refresh: false,
154
+ view: "vault",
155
+ openPath: null,
156
+ openDir: dir,
157
+ };
158
+ }
159
+
160
+ export function refresh(selection: StudioSelection): StudioSelection {
161
+ return { ...selection, refresh: true };
162
+ }
163
+
164
+ /** Omits defaults (the Dashboard view, an unset target), so URLs stay short. */
165
+ export function toFields(selection: StudioSelection): SelectionField[] {
166
+ const fields: SelectionField[] = [];
167
+ if (selection.companionDigest) {
168
+ fields.push({ name: COMPANION_PARAM, value: selection.companionDigest });
169
+ }
170
+ if (selection.view !== "dashboard") fields.push({ name: VIEW_PARAM, value: selection.view });
171
+ if (selection.view === "vault") {
172
+ if (selection.openPath) fields.push({ name: FILE_PARAM, value: selection.openPath });
173
+ if (selection.openDir) fields.push({ name: DIR_PARAM, value: selection.openDir });
174
+ }
175
+ if (selection.refresh) fields.push({ name: REFRESH_PARAM, value: "1" });
176
+ return fields;
177
+ }
178
+
179
+ export function toSearchParams(selection: StudioSelection): URLSearchParams {
180
+ return new URLSearchParams(toFields(selection).map(({ name, value }) => [name, value]));
181
+ }
182
+
183
+ export function toHref(selection: StudioSelection, route = "/"): string {
184
+ const query = toSearchParams(selection).toString();
185
+ return query ? `${route}?${query}` : route;
186
+ }
@@ -8,9 +8,15 @@ import {
8
8
  import { collectStudioInventory, type StudioInventory } from "./inventory";
9
9
  import { assembleCompanionPayload, type StudioCompanionResponse } from "./payload";
10
10
  import { STUDIO_HOSTNAME } from "./routes";
11
- import { companionDigest, parseStudioSelection, resolveCompanion } from "./selection";
11
+ import {
12
+ companionDigest,
13
+ parse,
14
+ parseVaultSelection,
15
+ resolveCompanion,
16
+ type StudioVaultSelection,
17
+ } from "./selection";
12
18
  import { createStudioSnapshotCache, type StudioSnapshotCache } from "./snapshot";
13
- import type { StudioPage } from "./views/model";
19
+ import type { StudioPage, StudioVaultPage } from "./views/model";
14
20
  import { createVaultManager, resolveVaultPath, VaultPathError, type VaultManager } from "./vault";
15
21
  import {
16
22
  DEFAULT_DETACH_MINUTES,
@@ -41,11 +47,17 @@ export interface StudioSocket {
41
47
 
42
48
  export interface StudioUpgrader {
43
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;
44
52
  }
45
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
+
46
57
  export interface StudioServeOptions {
47
58
  port: number;
48
59
  hostname: string;
60
+ idleTimeout: number;
49
61
  fetch: (request: Request, server: StudioUpgrader) => Promise<Response | undefined>;
50
62
  websocket?: {
51
63
  open(ws: StudioSocket): void;
@@ -97,6 +109,8 @@ export interface StudioServerOptions {
97
109
  detachMinutes?: number;
98
110
  /** Pins every terminal launch to this registered companion. */
99
111
  launchCompanion?: string | null;
112
+ /** Seconds; defaults to `STUDIO_IDLE_TIMEOUT_SECONDS`. */
113
+ idleTimeout?: number;
100
114
  /** Terminal launches skip companion Git synchronization. */
101
115
  noGit?: boolean;
102
116
  }
@@ -110,6 +124,22 @@ async function renderStudioDocument(page: StudioPage): Promise<string> {
110
124
  return views.renderStudioDocument(page);
111
125
  }
112
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
+
113
143
  function html(body: string): Response {
114
144
  return new Response(body, {
115
145
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
@@ -301,12 +331,11 @@ export function createStudioFetch(
301
331
  }
302
332
 
303
333
  if (url.pathname === "/api/vault/tree") {
304
- const selected = await selectedCompanion(url, inventory);
334
+ const selection = parseVaultSelection(url);
335
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
305
336
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
306
337
  try {
307
- return respond(
308
- json(await vault.tree(selected.path, url.searchParams.get("refresh") === "1")),
309
- );
338
+ return respond(json(await vault.tree(selected.path, selection.refresh)));
310
339
  } catch (error) {
311
340
  return respond(
312
341
  json({ reason: error instanceof Error ? error.message : String(error) }, 400),
@@ -314,11 +343,36 @@ export function createStudioFetch(
314
343
  }
315
344
  }
316
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
+
317
370
  if (url.pathname === "/api/vault/file") {
318
- const selected = await selectedCompanion(url, inventory);
371
+ const selection = parseVaultSelection(url);
372
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
319
373
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
320
374
  try {
321
- return respond(json(await vault.open(selected.path, url.searchParams.get("path") ?? "")));
375
+ return respond(json(await vault.open(selected.path, selection.openPath ?? "")));
322
376
  } catch (error) {
323
377
  return respond(
324
378
  json({ reason: error instanceof Error ? error.message : String(error) }, 400),
@@ -327,10 +381,12 @@ export function createStudioFetch(
327
381
  }
328
382
 
329
383
  if (url.pathname === "/api/vault/events") {
330
- const selected = await selectedCompanion(url, inventory);
384
+ const selection = parseVaultSelection(url);
385
+ const selected = await selectedCompanion(selection.companionDigest, inventory);
331
386
  if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
332
387
  try {
333
- const opened = await resolveVaultPath(selected.path, url.searchParams.get("path") ?? "");
388
+ const opened = await resolveVaultPath(selected.path, selection.openPath ?? "");
389
+ server?.timeout?.(request, 0);
334
390
  return respond(vaultEvents(vault, selected.path, opened.relative));
335
391
  } catch (error) {
336
392
  return respond(
@@ -355,7 +411,7 @@ export function createStudioFetch(
355
411
  return respond(json({ reason: "save requires a JSON body" }, 400));
356
412
  }
357
413
  const selected = await selectedCompanion(
358
- new URL(`${url.origin}/?companion=${encodeURIComponent(body.companion ?? "")}`),
414
+ typeof body.companion === "string" ? body.companion : null,
359
415
  inventory,
360
416
  );
361
417
  if (!selected || typeof body.path !== "string" || typeof body.content !== "string") {
@@ -403,11 +459,10 @@ export function createStudioFetch(
403
459
  }
404
460
 
405
461
  async function selectedCompanion(
406
- url: URL,
462
+ digest: string | null,
407
463
  collectInventory: () => Promise<StudioInventory>,
408
464
  ): Promise<ReturnType<typeof resolveCompanion>> {
409
- const inventory = await collectInventory();
410
- return resolveCompanion(inventory, parseStudioSelection(url).companionDigest);
465
+ return resolveCompanion(await collectInventory(), digest);
411
466
  }
412
467
 
413
468
  function vaultEvents(vault: VaultManager, companionPath: string, requestedPath: string): Response {
@@ -448,7 +503,7 @@ async function collectStudioPage(
448
503
  vault: VaultManager,
449
504
  writable: boolean,
450
505
  ): Promise<StudioPage> {
451
- const selection = parseStudioSelection(url);
506
+ const selection = parse(url);
452
507
  const inventory = await collectInventory();
453
508
  const companion = resolveCompanion(inventory, selection.companionDigest);
454
509
  const page: StudioPage = {
@@ -469,38 +524,10 @@ async function collectStudioPage(
469
524
  }
470
525
 
471
526
  if (selection.view === "vault") {
472
- try {
473
- const tree = await vault.tree(companion.path, selection.refresh);
474
- let open = null;
475
- let refusal: string | null = null;
476
- if (selection.openPath) {
477
- try {
478
- open = await vault.open(companion.path, selection.openPath);
479
- } catch (error) {
480
- refusal = error instanceof Error ? error.message : String(error);
481
- }
482
- }
483
- return {
484
- ...page,
485
- vault: {
486
- tree: tree.tree,
487
- open,
488
- refusal,
489
- incoming: null,
490
- overwritten: null,
491
- watching: tree.watching,
492
- warning: tree.warning,
493
- },
494
- };
495
- } catch (error) {
496
- return {
497
- ...page,
498
- error: {
499
- companionPath: companion.path,
500
- reason: error instanceof Error ? error.message : String(error),
501
- },
502
- };
503
- }
527
+ return {
528
+ ...page,
529
+ vault: await collectVaultState(vault, companion.path, selection, { tree: "prefetch" }),
530
+ };
504
531
  }
505
532
 
506
533
  vault.deactivate();
@@ -569,6 +596,7 @@ export function startStudioServer(
569
596
  const server = serve({
570
597
  port,
571
598
  hostname,
599
+ idleTimeout: options.idleTimeout ?? STUDIO_IDLE_TIMEOUT_SECONDS,
572
600
  fetch: fetchHandler,
573
601
  ...(registry
574
602
  ? {