@uniqbit/mate-core 0.16.0 → 0.17.0-canary.2

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/companion/companion.ts +8 -0
  3. package/src/cli/commands/companion/prepare.ts +76 -0
  4. package/src/cli/commands/companion/register.ts +47 -0
  5. package/src/cli/commands/launch/claude.ts +5 -3
  6. package/src/cli/commands/launch/opencode.ts +5 -3
  7. package/src/cli/commands/launch/shared.ts +41 -12
  8. package/src/cli/commands/shared/companion-selection.ts +3 -1
  9. package/src/cli/commands/studio/index.ts +85 -6
  10. package/src/cli/commands/studio/routes.ts +1 -1
  11. package/src/cli/commands/studio/selection.ts +11 -2
  12. package/src/cli/commands/studio/server.ts +212 -17
  13. package/src/cli/commands/studio/vault.ts +607 -0
  14. package/src/cli/commands/studio/views/client.ts +141 -1
  15. package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
  16. package/src/cli/commands/studio/views/document.tsx +136 -3
  17. package/src/cli/commands/studio/views/model.ts +13 -0
  18. package/src/cli/commands/studio/views/styles.ts +16 -0
  19. package/src/cli/main.ts +33 -4
  20. package/src/cli/usage.ts +6 -3
  21. package/src/index.ts +1 -0
  22. package/src/lib/context-mode-package.ts +7 -4
  23. package/src/lib/opencode-plugin-package.ts +10 -4
  24. package/src/lib/orchestrator/adapters/base.ts +65 -10
  25. package/src/lib/orchestrator/adapters/opencode.ts +4 -2
  26. package/src/lib/orchestrator/companion-registration.ts +73 -0
  27. package/src/lib/orchestrator/framework-context.ts +29 -3
  28. package/src/lib/orchestrator/launcher.ts +46 -22
  29. package/src/lib/orchestrator/projection-record.ts +24 -0
  30. package/src/lib/orchestrator/types.ts +11 -0
  31. package/src/lib/prebuilt-workspace-prepare.ts +152 -0
  32. package/src/lib/prebuilt-workspace.ts +236 -0
  33. package/src/lib/preinstalled-plugins.ts +82 -0
  34. package/src/lib/update-checker.ts +39 -0
  35. package/src/opencode/companion-hooks.ts +45 -20
  36. package/src/playbooks/companion-guidance.ts +1 -0
  37. package/src/runtime/companion-guidance.ts +61 -15
  38. package/src/runtime/env.ts +3 -3
  39. package/src/runtime/index.ts +1 -0
  40. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
  41. package/src/tools/setup/capabilities/context-mode.ts +5 -0
  42. package/src/tools/setup/plugin.ts +7 -1
  43. package/src/tools/setup/providers/opencode.ts +24 -5
@@ -4,6 +4,7 @@ import { STUDIO_HOSTNAME } from "./routes";
4
4
  import { parseStudioSelection, resolveCompanion } from "./selection";
5
5
  import { createStudioSnapshotCache, type StudioSnapshotCache } from "./snapshot";
6
6
  import type { StudioPage } from "./views/model";
7
+ import { createVaultManager, resolveVaultPath, VaultPathError, type VaultManager } from "./vault";
7
8
 
8
9
  export { STUDIO_HOSTNAME } from "./routes";
9
10
 
@@ -34,6 +35,13 @@ export interface StudioServerDeps {
34
35
  renderDocument?: (page: StudioPage) => string | Promise<string>;
35
36
  serve?: (options: StudioServeOptions) => StudioBoundServer;
36
37
  snapshots?: StudioSnapshotCache;
38
+ vault?: VaultManager;
39
+ }
40
+
41
+ export interface StudioServerOptions {
42
+ port?: number;
43
+ hostname?: string;
44
+ writable?: boolean;
37
45
  }
38
46
 
39
47
  /**
@@ -51,9 +59,21 @@ function html(body: string): Response {
51
59
  });
52
60
  }
53
61
 
62
+ function json(value: unknown, status = 200): Response {
63
+ return new Response(JSON.stringify(value), {
64
+ status,
65
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
66
+ });
67
+ }
68
+
69
+ interface StudioFetch {
70
+ (request: Request): Promise<Response>;
71
+ close(): void;
72
+ }
73
+
54
74
  /**
55
- * The studio request handler. Read-only by construction: a non-read method is
56
- * refused before any collection runs, and no route writes. The response is
75
+ * The studio request handler. Read endpoints are refused before collection, and
76
+ * the vault save route is the only write path. The response is
57
77
  * already correct for the URL that asked for it — the companion, the change,
58
78
  * and the view are read from the request rather than reconciled in the browser.
59
79
  * Collection is held in one snapshot cache for the life of the handler, so a
@@ -62,31 +82,148 @@ function html(body: string): Response {
62
82
  */
63
83
  export function createStudioFetch(
64
84
  deps: StudioServerDeps = {},
65
- ): (request: Request) => Promise<Response> {
85
+ options: Pick<StudioServerOptions, "writable"> = {},
86
+ ): StudioFetch {
66
87
  const inventory = deps.collectStudioInventory ?? collectStudioInventory;
67
88
  const companion = deps.assembleCompanionPayload ?? assembleCompanionPayload;
68
89
  const render = deps.renderDocument ?? renderStudioDocument;
69
90
  const snapshots =
70
91
  deps.snapshots ?? createStudioSnapshotCache({ assembleCompanionPayload: companion });
92
+ const vault = deps.vault ?? createVaultManager();
93
+ const writable = options.writable === true;
71
94
 
72
- return async (request: Request): Promise<Response> => {
73
- if (!READ_METHODS.has(request.method)) {
95
+ const handler = async (request: Request): Promise<Response> => {
96
+ const url = new URL(request.url);
97
+ const isSave = url.pathname === "/api/vault/save";
98
+ if (isSave && request.method !== "POST") {
99
+ return new Response("vault save requires POST", { status: 405, headers: { allow: "POST" } });
100
+ }
101
+ if (!READ_METHODS.has(request.method) && !(isSave && request.method === "POST")) {
74
102
  return new Response("studio serves read requests only", {
75
103
  status: 405,
76
- headers: { allow: "GET, HEAD" },
104
+ headers: { allow: isSave ? "POST" : "GET, HEAD" },
77
105
  });
78
106
  }
79
107
 
80
- const url = new URL(request.url);
81
108
  const respond = (response: Response) =>
82
109
  request.method === "HEAD"
83
110
  ? new Response(null, { status: response.status, headers: response.headers })
84
111
  : response;
85
112
 
113
+ if (url.pathname === "/api/vault/tree") {
114
+ const selected = await selectedCompanion(url, inventory);
115
+ if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
116
+ try {
117
+ return respond(
118
+ json(await vault.tree(selected.path, url.searchParams.get("refresh") === "1")),
119
+ );
120
+ } catch (error) {
121
+ return respond(
122
+ json({ reason: error instanceof Error ? error.message : String(error) }, 400),
123
+ );
124
+ }
125
+ }
126
+
127
+ if (url.pathname === "/api/vault/file") {
128
+ const selected = await selectedCompanion(url, inventory);
129
+ if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
130
+ try {
131
+ return respond(json(await vault.open(selected.path, url.searchParams.get("path") ?? "")));
132
+ } catch (error) {
133
+ return respond(
134
+ json({ reason: error instanceof Error ? error.message : String(error) }, 400),
135
+ );
136
+ }
137
+ }
138
+
139
+ if (url.pathname === "/api/vault/events") {
140
+ const selected = await selectedCompanion(url, inventory);
141
+ if (!selected) return respond(json({ reason: "no registered companion was selected" }, 400));
142
+ try {
143
+ const opened = await resolveVaultPath(selected.path, url.searchParams.get("path") ?? "");
144
+ return respond(vaultEvents(vault, selected.path, opened.relative));
145
+ } catch (error) {
146
+ return respond(
147
+ json({ reason: error instanceof Error ? error.message : String(error) }, 400),
148
+ );
149
+ }
150
+ }
151
+
152
+ if (isSave) {
153
+ if (!writable)
154
+ return respond(json({ reason: "start Studio with --writable to enable saving" }, 403));
155
+ let body: { companion?: string; path?: string; content?: string; token?: string };
156
+ try {
157
+ body = (await request.json()) as typeof body;
158
+ } catch {
159
+ return respond(json({ reason: "save requires a JSON body" }, 400));
160
+ }
161
+ const selected = await selectedCompanion(
162
+ new URL(`${url.origin}/?companion=${encodeURIComponent(body.companion ?? "")}`),
163
+ inventory,
164
+ );
165
+ if (!selected || typeof body.path !== "string" || typeof body.content !== "string") {
166
+ return respond(
167
+ json({ reason: "save requires a registered companion, path, and content" }, 400),
168
+ );
169
+ }
170
+ try {
171
+ const result = await vault.save(selected.path, body.path, body.content, body.token);
172
+ return respond(json(result, result.kind === "conflict" ? 409 : 200));
173
+ } catch (error) {
174
+ return respond(
175
+ json(
176
+ { reason: error instanceof Error ? error.message : String(error) },
177
+ error instanceof VaultPathError ? 400 : 500,
178
+ ),
179
+ );
180
+ }
181
+ }
182
+
86
183
  if (url.pathname !== "/") return respond(new Response("not found", { status: 404 }));
87
184
 
88
- return respond(html(await render(await collectStudioPage(url, inventory, snapshots))));
185
+ return respond(
186
+ html(await render(await collectStudioPage(url, inventory, snapshots, vault, writable))),
187
+ );
89
188
  };
189
+
190
+ handler.close = () => vault.stop();
191
+ return handler;
192
+ }
193
+
194
+ async function selectedCompanion(
195
+ url: URL,
196
+ collectInventory: () => Promise<StudioInventory>,
197
+ ): Promise<ReturnType<typeof resolveCompanion>> {
198
+ const inventory = await collectInventory();
199
+ return resolveCompanion(inventory, parseStudioSelection(url).companionDigest);
200
+ }
201
+
202
+ function vaultEvents(vault: VaultManager, companionPath: string, requestedPath: string): Response {
203
+ const encoder = new TextEncoder();
204
+ let unsubscribe = () => {};
205
+ const stream = new ReadableStream<Uint8Array>({
206
+ start(controller) {
207
+ controller.enqueue(encoder.encode(": studio vault events\n\n"));
208
+ unsubscribe = vault.subscribe(companionPath, requestedPath, (event) => {
209
+ try {
210
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
211
+ } catch {
212
+ unsubscribe();
213
+ }
214
+ });
215
+ },
216
+ cancel() {
217
+ unsubscribe();
218
+ },
219
+ });
220
+ return new Response(stream, {
221
+ headers: {
222
+ "content-type": "text/event-stream; charset=utf-8",
223
+ "cache-control": "no-cache",
224
+ connection: "keep-alive",
225
+ },
226
+ });
90
227
  }
91
228
 
92
229
  /**
@@ -97,6 +234,8 @@ async function collectStudioPage(
97
234
  url: URL,
98
235
  collectInventory: () => Promise<StudioInventory>,
99
236
  snapshots: StudioSnapshotCache,
237
+ vault: VaultManager,
238
+ writable: boolean,
100
239
  ): Promise<StudioPage> {
101
240
  const selection = parseStudioSelection(url);
102
241
  const inventory = await collectInventory();
@@ -108,9 +247,51 @@ async function collectStudioPage(
108
247
  payload: null,
109
248
  error: null,
110
249
  collectedAt: null,
250
+ writable,
251
+ vault: null,
111
252
  };
112
253
 
113
- if (!companion) return page;
254
+ if (!companion) {
255
+ vault.deactivate();
256
+ return page;
257
+ }
258
+
259
+ if (selection.view === "vault") {
260
+ try {
261
+ const tree = await vault.tree(companion.path, selection.refresh);
262
+ let open = null;
263
+ let refusal: string | null = null;
264
+ if (selection.openPath) {
265
+ try {
266
+ open = await vault.open(companion.path, selection.openPath);
267
+ } catch (error) {
268
+ refusal = error instanceof Error ? error.message : String(error);
269
+ }
270
+ }
271
+ return {
272
+ ...page,
273
+ vault: {
274
+ tree: tree.tree,
275
+ open,
276
+ refusal,
277
+ incoming: null,
278
+ overwritten: null,
279
+ watching: tree.watching,
280
+ warning: tree.warning,
281
+ },
282
+ };
283
+ } catch (error) {
284
+ return {
285
+ ...page,
286
+ error: {
287
+ companionPath: companion.path,
288
+ reason: error instanceof Error ? error.message : String(error),
289
+ },
290
+ };
291
+ }
292
+ }
293
+
294
+ vault.deactivate();
114
295
 
115
296
  try {
116
297
  const snapshot = await snapshots.read(companion.path, selection.refresh);
@@ -144,19 +325,33 @@ function bunServe(options: StudioServeOptions): StudioBoundServer {
144
325
  }
145
326
 
146
327
  /**
147
- * Binds the studio server to an operating-system-assigned loopback port. A
148
- * bind failure propagates: the caller reports it and exits rather than opening
149
- * a browser at a URL nothing answers.
328
+ * A bind failure propagates: the caller reports it and exits rather than
329
+ * opening a browser at a URL nothing answers.
150
330
  */
151
- export function startStudioServer(deps: StudioServerDeps = {}): StudioServerHandle {
331
+ export function startStudioServer(
332
+ deps: StudioServerDeps = {},
333
+ options: StudioServerOptions = {},
334
+ ): StudioServerHandle {
152
335
  const serve = deps.serve ?? bunServe;
153
- const server = serve({ port: 0, hostname: STUDIO_HOSTNAME, fetch: createStudioFetch(deps) });
336
+ const port = options.port ?? 0;
337
+ const hostname = options.hostname ?? STUDIO_HOSTNAME;
338
+ const fetchHandler = createStudioFetch(deps, { writable: options.writable });
339
+ const server = serve({ port, hostname, fetch: fetchHandler });
340
+ const urlHostname =
341
+ hostname === STUDIO_HOSTNAME || hostname === "localhost" || hostname === "::1"
342
+ ? "localhost"
343
+ : hostname.includes(":") && !hostname.startsWith("[")
344
+ ? `[${hostname}]`
345
+ : hostname;
154
346
 
155
347
  return {
156
- url: `http://localhost:${server.port}`,
348
+ url: `http://${urlHostname}:${server.port}`,
157
349
  port: server.port,
158
- hostname: STUDIO_HOSTNAME,
159
- stop: () => server.stop(true),
350
+ hostname,
351
+ stop: async () => {
352
+ fetchHandler.close();
353
+ await server.stop(true);
354
+ },
160
355
  };
161
356
  }
162
357