@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
@@ -1,23 +1,39 @@
1
1
  import { spawn } from "node:child_process";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
 
4
5
  import { FRAMEWORK_NAME } from "../../../framework";
5
6
  import { MATE_ENV } from "../../../runtime/env-names";
6
- import { buildProjection, projectionEnvironment } from "../projection-record";
7
+ import { buildProjection, companionEnvironment, projectionEnvironment } from "../projection-record";
7
8
  import type { CapabilityConfig, GitModeProfile, LinkedRepository } from "../types";
8
9
 
9
10
  export interface AdapterContext {
10
- repository: LinkedRepository;
11
+ repository?: LinkedRepository;
12
+ launchWorkingDirectory: string;
11
13
  allowedAgents: string[];
12
14
  companionPath: string;
13
15
  capabilities: CapabilityConfig[];
14
16
  git?: GitModeProfile;
17
+ skipGit?: boolean;
15
18
  }
16
19
 
17
20
  export interface AdapterResult {
18
21
  exitCode: number;
19
22
  stdout: string;
20
23
  stderr: string;
24
+ /** Set when the agent process ended on a signal rather than its own exit. */
25
+ signal?: NodeJS.Signals | null;
26
+ }
27
+
28
+ /** Forwarded from the launch to the agent so a supervisor above it can stop a session. */
29
+ const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
30
+
31
+ /**
32
+ * The shell convention: a signalled stop reads as 128 + the signal number, so a
33
+ * caller can tell it from an ordinary non-zero exit.
34
+ */
35
+ export function signalExitCode(signal: NodeJS.Signals): number {
36
+ return 128 + (os.constants.signals[signal] ?? 0);
21
37
  }
22
38
 
23
39
  export interface PreparedLaunch {
@@ -50,19 +66,26 @@ export abstract class LaunchAdapter {
50
66
  */
51
67
  environment(context: AdapterContext): NodeJS.ProcessEnv {
52
68
  const reactDoctorEnabled = context.capabilities.some((c) => c.name === "react-doctor");
53
- const projection = buildProjection(context.companionPath, context.repository);
69
+ const projection = context.repository
70
+ ? projectionEnvironment(buildProjection(context.companionPath, context.repository))
71
+ : companionEnvironment(context.companionPath);
54
72
  const env: NodeJS.ProcessEnv = {
55
73
  ...process.env,
56
- ...projectionEnvironment(projection),
74
+ ...projection,
57
75
  MATE_NAME: FRAMEWORK_NAME,
58
- PATH: prependPathEntry(process.env.PATH, projection.wrapperBinPath),
76
+ PATH: prependPathEntry(process.env.PATH, projection[MATE_ENV.wrapperBinPath] ?? ""),
59
77
  MATE_GRAPHIFY_ENABLED: context.capabilities.some((c) => c.name === "graphify") ? "1" : "0",
60
78
  MATE_OPENSPEC_ENABLED: context.capabilities.some((c) => c.name === "openspec") ? "1" : "0",
61
79
  MATE_REACT_DOCTOR_ENABLED: reactDoctorEnabled ? "1" : "0",
62
- MATE_GIT_AUTO_MODE: context.git === "auto" ? "1" : "0",
80
+ MATE_GIT_AUTO_MODE: context.git === "auto" && !context.skipGit ? "1" : "0",
63
81
  MATE_POLICY_JSON: JSON.stringify({ allowedAgents: context.allowedAgents }),
64
82
  };
65
83
 
84
+ if (!context.repository) {
85
+ delete env[MATE_ENV.repositoryPath];
86
+ delete env[MATE_ENV.repositoryId];
87
+ }
88
+
66
89
  /** The one field where a launch still narrows the projection rather than materializing it. */
67
90
  if (!reactDoctorEnabled) delete env[MATE_ENV.reactDoctorBinPath];
68
91
 
@@ -87,12 +110,22 @@ export abstract class LaunchAdapter {
87
110
  return { command: this.toolName, args: builtArgs, env };
88
111
  }
89
112
 
113
+ /**
114
+ * A terminal delivers `SIGINT` to the whole foreground process group, so an
115
+ * interactive launch at a TTY must not forward it as well — the agent would
116
+ * be stopped twice. Where the signal reaches the launch alone (a supervisor,
117
+ * a container entrypoint), forwarding is the only way it reaches the agent.
118
+ */
119
+ protected forwardsSignals(): boolean {
120
+ return !(this.interactive && process.stdin.isTTY);
121
+ }
122
+
90
123
  async run(context: AdapterContext, args: string[]): Promise<AdapterResult> {
91
124
  const launch = await this.prepareLaunch(context, args);
92
125
 
93
126
  return new Promise((resolve, reject) => {
94
127
  const child = spawn(launch.command, launch.args, {
95
- cwd: context.repository.path,
128
+ cwd: context.launchWorkingDirectory,
96
129
  env: launch.env,
97
130
  stdio: this.interactive ? "inherit" : "pipe",
98
131
  });
@@ -109,12 +142,34 @@ export abstract class LaunchAdapter {
109
142
  });
110
143
  }
111
144
 
112
- child.on("error", reject);
113
- child.on("close", (exitCode) => {
145
+ const forward = this.forwardsSignals();
146
+ const handlers = new Map<NodeJS.Signals, () => void>();
147
+ for (const signal of FORWARDED_SIGNALS) {
148
+ const handler = () => {
149
+ /** Installing a handler at all is what keeps the launch alive to wait for the agent. */
150
+ if (forward && child.exitCode === null && child.signalCode === null) {
151
+ child.kill(signal);
152
+ }
153
+ };
154
+ handlers.set(signal, handler);
155
+ process.on(signal, handler);
156
+ }
157
+ const removeHandlers = () => {
158
+ for (const [signal, handler] of handlers) process.off(signal, handler);
159
+ handlers.clear();
160
+ };
161
+
162
+ child.on("error", (error) => {
163
+ removeHandlers();
164
+ reject(error);
165
+ });
166
+ child.on("close", (exitCode, signal) => {
167
+ removeHandlers();
114
168
  resolve({
115
- exitCode: exitCode ?? 1,
169
+ exitCode: exitCode ?? (signal ? signalExitCode(signal) : 1),
116
170
  stdout,
117
171
  stderr,
172
+ signal: signal ?? null,
118
173
  });
119
174
  });
120
175
  });
@@ -29,7 +29,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
29
29
  ] as const;
30
30
 
31
31
  buildArgs(context: AdapterContext, args: string[]): string[] {
32
- const codeDir = context.repository.path;
32
+ const codeDir = context.launchWorkingDirectory;
33
33
  return args.length === 0 || args[0]?.startsWith("-") ? [codeDir, ...args] : args;
34
34
  }
35
35
 
@@ -55,7 +55,9 @@ export class OpenCodeAdapter extends LaunchAdapter {
55
55
  }
56
56
 
57
57
  private buildGuidance(context: AdapterContext): MateGuidanceFile {
58
- return buildOpenCodeGuidance(context.capabilities);
58
+ return buildOpenCodeGuidance(context.capabilities, {
59
+ companionScoped: !context.repository,
60
+ });
59
61
  }
60
62
 
61
63
  async validateLaunch(context: AdapterContext): Promise<void> {
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { parse } from "yaml";
5
+
6
+ import { FRAMEWORK_NAME } from "../../framework";
7
+ import { GlobalConfigStore } from "./global-config-store";
8
+ import type { FrameworkType } from "./types";
9
+
10
+ export type CompanionRegistrationResult =
11
+ | { ok: true; companionPath: string; alreadyRegistered: boolean }
12
+ | { ok: false; companionPath: string; reason: string };
13
+
14
+ export interface RegisterConfiguredCompanionDeps {
15
+ globalConfigStore?: Pick<GlobalConfigStore, "list" | "register">;
16
+ readFile?: typeof fs.readFile;
17
+ }
18
+
19
+ function configPathFor(companionPath: string): string {
20
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
21
+ }
22
+
23
+ /**
24
+ * Registration for a Companion Repository that already carries its own
25
+ * configuration: never presents a selection, never reads stdin, and never
26
+ * writes the companion's configuration — only the global registry entry.
27
+ */
28
+ export async function registerConfiguredCompanion(
29
+ rawCompanionPath: string,
30
+ deps: RegisterConfiguredCompanionDeps = {},
31
+ ): Promise<CompanionRegistrationResult> {
32
+ const companionPath = path.resolve(rawCompanionPath);
33
+ const readFile = deps.readFile ?? fs.readFile;
34
+ const store = deps.globalConfigStore ?? new GlobalConfigStore();
35
+
36
+ let raw: string;
37
+ try {
38
+ raw = await readFile(configPathFor(companionPath), "utf8");
39
+ } catch {
40
+ return {
41
+ ok: false,
42
+ companionPath,
43
+ reason: `${companionPath} carries no companion configuration (${path.join(`.${FRAMEWORK_NAME}`, "config", "framework.yaml")} is missing); there is nothing to register.`,
44
+ };
45
+ }
46
+
47
+ let declaredType: FrameworkType | undefined;
48
+ try {
49
+ declaredType = (parse(raw) as { type?: FrameworkType } | null)?.type;
50
+ } catch {
51
+ return {
52
+ ok: false,
53
+ companionPath,
54
+ reason: `${companionPath} carries an unreadable companion configuration; there is nothing to register.`,
55
+ };
56
+ }
57
+
58
+ /** A missing type predates the field and is a companion; anything else is not one. */
59
+ if (declaredType !== undefined && declaredType !== "companion") {
60
+ return {
61
+ ok: false,
62
+ companionPath,
63
+ reason: `${companionPath} is a "${declaredType}" framework, not a companion; there is nothing to register.`,
64
+ };
65
+ }
66
+
67
+ const alreadyRegistered = (await store.list()).some(
68
+ (entry) => path.resolve(entry) === companionPath,
69
+ );
70
+ await store.register(companionPath);
71
+
72
+ return { ok: true, companionPath, alreadyRegistered };
73
+ }
@@ -17,6 +17,7 @@ import {
17
17
  type FrameworkConfig,
18
18
  type HubConfig,
19
19
  type LinkedRepository,
20
+ type LaunchScope,
20
21
  RepositoryNotFoundError,
21
22
  WorkingRepoRequiredError,
22
23
  } from "./types";
@@ -187,14 +188,18 @@ export async function resolveFrameworkContext(
187
188
  // Resolution order:
188
189
  // 1. MATE_ARTIFACT_PATH + MATE_REPO_ID env vars (agent-launched sessions)
189
190
  // 2. CompanionResolver — cwd inside a linked working repo
190
- // Does NOT fall back to local config — launching from the companion root is invalid.
191
- // Use resolveForCapability if the caller may run from the companion directory.
191
+ // A companion-scoped launch may also resolve the local companion config from cwd.
192
192
  export async function resolveForLaunch(
193
193
  cwd: string,
194
194
  globalConfigStore = new GlobalConfigStore(),
195
+ scope: LaunchScope = "working-repo",
195
196
  ): Promise<LaunchContext> {
196
197
  const envCompanionPath = process.env.MATE_ARTIFACT_PATH;
197
198
  if (envCompanionPath) {
199
+ if (scope === "companion") {
200
+ const context = await withResolvedHub(makeContext(path.resolve(envCompanionPath), "env"));
201
+ return { ...context, repositoryId: "" };
202
+ }
198
203
  const repository = repositoryFromEnvironment() ?? (await findRepoLocalLinkedRepository(cwd));
199
204
  const repositoryId = process.env.MATE_REPO_ID ?? repository?.id ?? "";
200
205
  const context = await withResolvedHub(
@@ -215,6 +220,12 @@ export async function resolveForLaunch(
215
220
 
216
221
  if (resolution.match) {
217
222
  const repository = (await findRepoLocalLinkedRepository(cwd)) ?? undefined;
223
+ if (scope === "companion") {
224
+ const context = await withResolvedHub(
225
+ makeContext(resolution.match.companionPath, "working-repo"),
226
+ );
227
+ return { ...context, repositoryId: "" };
228
+ }
218
229
  if (repository) {
219
230
  await backfillCompanionRegistration(resolution.match.companionPath, repository);
220
231
  await projectWorkingRepositoryBestEffort(resolution.match.companionPath, repository);
@@ -228,6 +239,21 @@ export async function resolveForLaunch(
228
239
  };
229
240
  }
230
241
 
242
+ if (scope === "companion") {
243
+ const localConfigPath = path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
244
+ let localConfig: Partial<FrameworkConfig> | null;
245
+ try {
246
+ localConfig = parse(await fs.readFile(localConfigPath, "utf8")) as Partial<FrameworkConfig>;
247
+ } catch {
248
+ localConfig = null;
249
+ }
250
+ if (localConfig?.type !== "companion") {
251
+ throw new RepositoryNotFoundError(`No companion found for current directory: ${cwd}`);
252
+ }
253
+ const context = await withResolvedHub(makeContext(cwd, "companion-root"));
254
+ return { ...context, repositoryId: "" };
255
+ }
256
+
231
257
  throw new WorkingRepoRequiredError();
232
258
  }
233
259
 
@@ -259,7 +285,7 @@ async function backfillCompanionRegistration(
259
285
  // Resolves context for `mate cap` commands. Unlike resolveForLaunch, this allows
260
286
  // running from the companion directory itself — cap commands manage companion state
261
287
  // and don't need a working-repo cwd. resolveForLaunch intentionally lacks this
262
- // fallback because launching an agent from the companion root is not a valid scenario.
288
+ // fallback because its implicit launch scope still requires a Working Repository.
263
289
  export async function resolveForCapability(
264
290
  cwd: string,
265
291
  globalConfigStore = new GlobalConfigStore(),
@@ -28,12 +28,13 @@ import {
28
28
  type LaunchRequest,
29
29
  type LaunchResult,
30
30
  type LinkedRepository,
31
+ type LaunchScope,
31
32
  } from "./types";
32
33
 
33
34
  export interface LaunchPreview {
34
35
  tool: string;
35
- repositoryId: string;
36
- repositoryPath: string;
36
+ repositoryId?: string;
37
+ repositoryPath?: string;
37
38
  companionPath: string;
38
39
  }
39
40
 
@@ -45,7 +46,10 @@ interface ResolvedLaunchState {
45
46
  adapter: LaunchAdapter;
46
47
  companionPath: string;
47
48
  config: FrameworkConfig;
48
- repository: LinkedRepository;
49
+ repository?: LinkedRepository;
50
+ scope: LaunchScope;
51
+ launchWorkingDirectory: string;
52
+ skipGit: boolean;
49
53
  }
50
54
 
51
55
  export const launcherDeps = {
@@ -108,9 +112,10 @@ export class FrameworkLauncher {
108
112
  const state = await this.resolveLaunchState(request);
109
113
  return {
110
114
  tool: request.tool,
111
- repositoryId: state.repository.id,
112
- repositoryPath: state.repository.path,
113
115
  companionPath: state.companionPath,
116
+ ...(state.repository
117
+ ? { repositoryId: state.repository.id, repositoryPath: state.repository.path }
118
+ : {}),
114
119
  };
115
120
  }
116
121
 
@@ -118,27 +123,37 @@ export class FrameworkLauncher {
118
123
  const state = await this.resolveLaunchState(request);
119
124
  const adapterContext = this.makeAdapterContext(state);
120
125
 
121
- if (await launcherDeps.isWrapped(state.repository.path)) {
126
+ if (state.repository && (await launcherDeps.isWrapped(state.repository.path))) {
122
127
  throw new LaunchPreflightError(wrappedRepositoryRefusal(state.repository));
123
128
  }
124
129
 
125
130
  if (!request.skipGit && state.config.git === "auto") {
126
131
  await launcherDeps.syncCompanionGit(
127
132
  state.companionPath,
128
- state.repository.path,
133
+ state.repository?.path,
129
134
  request.interactiveGit ?? false,
130
135
  );
131
136
  }
132
- await launcherDeps.syncCompanionFiles(state.companionPath, state.config, state.repository.path);
133
- this.answerForProjection(
134
- await launcherDeps.projectWorkingRepo({
135
- repoPath: state.repository.path,
136
- companionPath: state.companionPath,
137
- config: state.config,
138
- }),
139
- state.repository,
140
- );
141
- await launcherDeps.refreshProjectionRoot(state.companionPath, state.repository);
137
+ if (state.repository) {
138
+ await launcherDeps.syncCompanionFiles(
139
+ state.companionPath,
140
+ state.config,
141
+ state.repository.path,
142
+ );
143
+ } else {
144
+ await launcherDeps.syncCompanionFiles(state.companionPath, state.config);
145
+ }
146
+ if (state.repository) {
147
+ this.answerForProjection(
148
+ await launcherDeps.projectWorkingRepo({
149
+ repoPath: state.repository.path,
150
+ companionPath: state.companionPath,
151
+ config: state.config,
152
+ }),
153
+ state.repository,
154
+ );
155
+ await launcherDeps.refreshProjectionRoot(state.companionPath, state.repository);
156
+ }
142
157
  await this.runCapabilityPreflight(state, request.tool);
143
158
  await state.adapter.validateLaunch(adapterContext);
144
159
 
@@ -158,10 +173,14 @@ export class FrameworkLauncher {
158
173
  companionPath,
159
174
  repositoryId,
160
175
  repository: localRepository,
161
- } = await this.resolveConfig();
176
+ } = await this.resolveConfig(request.scope);
162
177
  const store = new CompanionStore(configStore, workingRepoStore);
163
- const repository = localRepository ?? (await store.getRepository(repositoryId));
164
- if (!repository) {
178
+ const scope = request.scope ?? "working-repo";
179
+ const repository =
180
+ scope === "companion"
181
+ ? undefined
182
+ : (localRepository ?? (await store.getRepository(repositoryId)));
183
+ if (scope === "working-repo" && !repository) {
165
184
  throw new RepositoryNotSelectedError(`Linked repository not found: ${repositoryId}`);
166
185
  }
167
186
 
@@ -180,6 +199,9 @@ export class FrameworkLauncher {
180
199
  companionPath,
181
200
  config,
182
201
  repository,
202
+ scope,
203
+ launchWorkingDirectory: scope === "companion" ? companionPath : repository!.path,
204
+ skipGit: request.skipGit ?? false,
183
205
  };
184
206
  }
185
207
 
@@ -210,8 +232,10 @@ export class FrameworkLauncher {
210
232
  repository: state.repository,
211
233
  allowedAgents: state.config.allowedAgents,
212
234
  companionPath: state.companionPath,
235
+ launchWorkingDirectory: state.launchWorkingDirectory,
213
236
  capabilities: state.config.capabilities ?? [],
214
237
  git: state.config.git,
238
+ skipGit: state.skipGit,
215
239
  };
216
240
  }
217
241
 
@@ -263,7 +287,7 @@ export class FrameworkLauncher {
263
287
  }
264
288
  }
265
289
 
266
- private async resolveConfig(): Promise<LaunchContext> {
267
- return resolveForLaunch(process.cwd());
290
+ private async resolveConfig(scope: LaunchScope = "working-repo"): Promise<LaunchContext> {
291
+ return resolveForLaunch(process.cwd(), undefined, scope);
268
292
  }
269
293
  }
@@ -13,6 +13,8 @@ import {
13
13
  import { getReactDoctorBinPath, getWrapperBinPath } from "../package-paths";
14
14
  import type { LinkedRepository } from "./types";
15
15
 
16
+ export const COMPANION_GRAPHIFY_REPOSITORY_ID = "__companion__";
17
+
16
18
  /**
17
19
  * The one derivation of the projected paths. Both the Projection Root and a
18
20
  * managed launch materialize this record, so neither can hold its own copy of a
@@ -54,3 +56,25 @@ export function projectionEnvironment(projection: MateProjection): NodeJS.Proces
54
56
  PROJECTION_FIELDS.map((field) => [PROJECTION_ENV_NAMES[field], projection[field]]),
55
57
  );
56
58
  }
59
+
60
+ /** Materializes the path-only subset used by a launch without a repository. */
61
+ export function companionEnvironment(companionPath: string): NodeJS.ProcessEnv {
62
+ const values: Record<keyof MateProjection, string> = {
63
+ version,
64
+ companionPath,
65
+ repositoryPath: "",
66
+ repositoryId: "",
67
+ wrapperBinPath: getWrapperBinPath(),
68
+ reactDoctorBinPath: getReactDoctorBinPath(),
69
+ graphifyOut: path.join(
70
+ companionPath,
71
+ GRAPHIFY_STORE_SEGMENT,
72
+ COMPANION_GRAPHIFY_REPOSITORY_ID,
73
+ GRAPHIFY_OUTPUT_SUBDIR,
74
+ ),
75
+ };
76
+
77
+ return Object.fromEntries(
78
+ PROJECTION_FIELDS.map((field) => [PROJECTION_ENV_NAMES[field], values[field]]),
79
+ );
80
+ }
@@ -35,6 +35,12 @@ export class WorkingRepoRequiredError extends MateError {
35
35
  " mate companion link",
36
36
  "",
37
37
  " To see linked repositories: mate companion list",
38
+ ...(command === "launch"
39
+ ? [
40
+ "",
41
+ " To launch from the companion itself: `mate claude -- --companion` or `mate opencode -- --companion`",
42
+ ]
43
+ : []),
38
44
  ].join("\n"),
39
45
  );
40
46
  }
@@ -131,9 +137,12 @@ export interface CompanionRegistryConfig {
131
137
  repos: LinkedRepository[];
132
138
  }
133
139
 
140
+ export type LaunchScope = "working-repo" | "companion";
141
+
134
142
  export interface LaunchRequest {
135
143
  tool: string;
136
144
  args: string[];
145
+ scope?: LaunchScope;
137
146
  skipGit?: boolean;
138
147
  interactiveGit?: boolean;
139
148
  }
@@ -142,4 +151,6 @@ export interface LaunchResult {
142
151
  exitCode: number;
143
152
  stdout: string;
144
153
  stderr: string;
154
+ /** Set when the agent process ended on a signal rather than its own exit. */
155
+ signal?: NodeJS.Signals | null;
145
156
  }
@@ -0,0 +1,152 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { PREBUILT_BUNDLE_MARKER as BUNDLE_MARKER } from "./preinstalled-plugins";
6
+ import {
7
+ currentTarget,
8
+ getLocalWorkspaceDir,
9
+ validatePrebuiltWorkspace,
10
+ type PrebuiltWorkspaceTarget,
11
+ } from "./prebuilt-workspace";
12
+
13
+ export type PreparePrebuiltWorkspaceResult =
14
+ | { ok: true; workspacePath: string; reused: boolean; packages: Record<string, string> }
15
+ | { ok: false; workspacePath: string; failures: string[] };
16
+
17
+ export const prebuiltWorkspaceDeps = {
18
+ /** Filesystem-only: a plain recursive copy, never a package manager or an install script. */
19
+ copy: (source: string, destination: string) =>
20
+ fs.cp(source, destination, { recursive: true, force: true, errorOnExist: false }),
21
+ };
22
+
23
+ /**
24
+ * Content identity of an installed tree: a version match is not enough, since
25
+ * two bundles can pin the same versions and still differ.
26
+ */
27
+ export async function fingerprintWorkspace(root: string): Promise<string> {
28
+ const hash = crypto.createHash("sha256");
29
+ const walk = async (dir: string, prefix: string): Promise<void> => {
30
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
31
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
32
+ if (prefix === "" && entry.name === BUNDLE_MARKER) continue;
33
+ const full = path.join(dir, entry.name);
34
+ const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
35
+ if (entry.isDirectory()) {
36
+ hash.update(`d:${relative}\n`);
37
+ await walk(full, relative);
38
+ continue;
39
+ }
40
+ if (entry.isSymbolicLink()) {
41
+ hash.update(`l:${relative}:${await fs.readlink(full).catch(() => "")}\n`);
42
+ continue;
43
+ }
44
+ hash.update(`f:${relative}:`);
45
+ hash.update(await fs.readFile(full));
46
+ hash.update("\n");
47
+ }
48
+ };
49
+ await walk(path.resolve(root), "");
50
+ return hash.digest("hex");
51
+ }
52
+
53
+ async function readBundleMarker(workspacePath: string): Promise<string | null> {
54
+ try {
55
+ const marker = JSON.parse(
56
+ await fs.readFile(path.join(workspacePath, BUNDLE_MARKER), "utf8"),
57
+ ) as { fingerprint?: string };
58
+ return marker.fingerprint ?? null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function samePackages(a: Record<string, string>, b: Record<string, string>): boolean {
65
+ const names = new Set([...Object.keys(a), ...Object.keys(b)]);
66
+ for (const name of names) if (a[name] !== b[name]) return false;
67
+ return true;
68
+ }
69
+
70
+ async function exists(target: string): Promise<boolean> {
71
+ return fs.access(target).then(
72
+ () => true,
73
+ () => false,
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Copies a validated prebuilt workspace into the companion's machine-local
79
+ * workspace. Presents nothing, waits for nothing, reads no selection and
80
+ * writes none. A changed bundle is staged and validated before it replaces the
81
+ * framework-owned generated workspace; any failure leaves the existing
82
+ * workspace exactly as it was.
83
+ */
84
+ export async function preparePrebuiltWorkspace(
85
+ companionPath: string,
86
+ bundlePath: string,
87
+ target: PrebuiltWorkspaceTarget = currentTarget(),
88
+ ): Promise<PreparePrebuiltWorkspaceResult> {
89
+ const workspacePath = getLocalWorkspaceDir(companionPath);
90
+ const source = path.resolve(bundlePath);
91
+
92
+ const bundle = await validatePrebuiltWorkspace(source, target);
93
+ if (!bundle.ok) return { ok: false, workspacePath, failures: bundle.failures };
94
+
95
+ const fingerprint = await fingerprintWorkspace(source);
96
+ if (await exists(workspacePath)) {
97
+ const existing = await validatePrebuiltWorkspace(workspacePath, target);
98
+ if (
99
+ existing.ok &&
100
+ samePackages(existing.packages, bundle.packages) &&
101
+ (await readBundleMarker(workspacePath)) === fingerprint
102
+ ) {
103
+ return { ok: true, workspacePath, reused: true, packages: existing.packages };
104
+ }
105
+ }
106
+
107
+ const staging = `${workspacePath}.staging`;
108
+ const previous = `${workspacePath}.previous`;
109
+ await fs.rm(staging, { recursive: true, force: true });
110
+ await fs.rm(previous, { recursive: true, force: true });
111
+
112
+ try {
113
+ await fs.mkdir(path.dirname(workspacePath), { recursive: true });
114
+ await prebuiltWorkspaceDeps.copy(source, staging);
115
+ } catch (error) {
116
+ await fs.rm(staging, { recursive: true, force: true });
117
+ return {
118
+ ok: false,
119
+ workspacePath,
120
+ failures: [`${source}: copying the bundle failed: ${(error as Error).message}`],
121
+ };
122
+ }
123
+
124
+ const staged = await validatePrebuiltWorkspace(staging, target);
125
+ if (!staged.ok) {
126
+ await fs.rm(staging, { recursive: true, force: true });
127
+ return { ok: false, workspacePath, failures: staged.failures };
128
+ }
129
+
130
+ await fs.writeFile(
131
+ path.join(staging, BUNDLE_MARKER),
132
+ `${JSON.stringify({ fingerprint, packages: staged.packages }, null, 2)}\n`,
133
+ "utf8",
134
+ );
135
+
136
+ const hadExisting = await exists(workspacePath);
137
+ try {
138
+ if (hadExisting) await fs.rename(workspacePath, previous);
139
+ await fs.rename(staging, workspacePath);
140
+ } catch (error) {
141
+ await fs.rm(staging, { recursive: true, force: true });
142
+ if (hadExisting && !(await exists(workspacePath))) await fs.rename(previous, workspacePath);
143
+ return {
144
+ ok: false,
145
+ workspacePath,
146
+ failures: [`${workspacePath}: replacing the workspace failed: ${(error as Error).message}`],
147
+ };
148
+ }
149
+ await fs.rm(previous, { recursive: true, force: true });
150
+
151
+ return { ok: true, workspacePath, reused: false, packages: staged.packages };
152
+ }