@uniqbit/mate-core 0.16.0 → 0.17.0-canary.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.
- package/package.json +1 -1
- package/src/cli/commands/companion/companion.ts +8 -0
- package/src/cli/commands/companion/prepare.ts +76 -0
- package/src/cli/commands/companion/register.ts +47 -0
- package/src/cli/commands/launch/claude.ts +5 -3
- package/src/cli/commands/launch/opencode.ts +5 -3
- package/src/cli/commands/launch/shared.ts +41 -12
- package/src/cli/commands/shared/companion-selection.ts +3 -1
- package/src/cli/commands/studio/index.ts +85 -6
- package/src/cli/commands/studio/routes.ts +1 -1
- package/src/cli/commands/studio/selection.ts +11 -2
- package/src/cli/commands/studio/server.ts +212 -17
- package/src/cli/commands/studio/vault.ts +607 -0
- package/src/cli/commands/studio/views/client.ts +141 -1
- package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
- package/src/cli/commands/studio/views/document.tsx +136 -3
- package/src/cli/commands/studio/views/model.ts +13 -0
- package/src/cli/commands/studio/views/styles.ts +16 -0
- package/src/cli/main.ts +33 -4
- package/src/cli/usage.ts +6 -3
- package/src/index.ts +1 -0
- package/src/lib/context-mode-package.ts +7 -4
- package/src/lib/opencode-plugin-package.ts +10 -4
- package/src/lib/orchestrator/adapters/base.ts +65 -10
- package/src/lib/orchestrator/adapters/opencode.ts +4 -2
- package/src/lib/orchestrator/companion-registration.ts +73 -0
- package/src/lib/orchestrator/framework-context.ts +29 -3
- package/src/lib/orchestrator/launcher.ts +46 -22
- package/src/lib/orchestrator/projection-record.ts +24 -0
- package/src/lib/orchestrator/types.ts +11 -0
- package/src/lib/prebuilt-workspace-prepare.ts +152 -0
- package/src/lib/prebuilt-workspace.ts +236 -0
- package/src/lib/preinstalled-plugins.ts +82 -0
- package/src/lib/update-checker.ts +39 -0
- package/src/opencode/companion-hooks.ts +45 -20
- package/src/playbooks/companion-guidance.ts +1 -0
- package/src/runtime/companion-guidance.ts +61 -15
- package/src/runtime/env.ts +3 -3
- package/src/runtime/index.ts +1 -0
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
- package/src/tools/setup/capabilities/context-mode.ts +5 -0
- package/src/tools/setup/plugin.ts +7 -1
- 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
|
|
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 =
|
|
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
|
-
...
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
|
36
|
-
repositoryPath
|
|
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
|
|
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
|
|
133
|
+
state.repository?.path,
|
|
129
134
|
request.interactiveGit ?? false,
|
|
130
135
|
);
|
|
131
136
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
state.
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
164
|
-
|
|
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
|
+
}
|