@uniqbit/mate-core 0.18.1-canary.0 → 0.18.1-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 (61) hide show
  1. package/claude-plugin/hooks/session-banner.mjs +1 -1
  2. package/opencode-plugin/server.ts +5 -0
  3. package/opencode-plugin/src/add-dir.ts +118 -0
  4. package/opencode-plugin/src/companion.ts +180 -0
  5. package/opencode-plugin/src/server.ts +26 -0
  6. package/opencode-plugin/src/tui.tsx +1 -0
  7. package/opencode-plugin/tui.tsx +2 -0
  8. package/package.json +13 -6
  9. package/src/cli/commands/artifact/finish/engine.ts +1 -1
  10. package/src/cli/commands/artifact/finish/git.ts +75 -51
  11. package/src/cli/commands/companion/hub.ts +1 -1
  12. package/src/cli/commands/companion/link.ts +13 -59
  13. package/src/cli/commands/doctor.ts +30 -4
  14. package/src/cli/commands/install.ts +17 -1
  15. package/src/cli/commands/plugin/plugin.ts +0 -4
  16. package/src/cli/commands/report/formatter.ts +0 -4
  17. package/src/cli/commands/studio/selection.ts +3 -0
  18. package/src/cli/commands/studio/terminal-launch.ts +1 -51
  19. package/src/cli/commands/studio/terminal.ts +2 -13
  20. package/src/cli/commands/studio/vault.ts +5 -3
  21. package/src/cli/commands/studio/views/workflow/steps.ts +5 -4
  22. package/src/cli/commands/update.ts +4 -20
  23. package/src/cli/commands/working/cleanup.ts +4 -1
  24. package/src/cli/launch-selector.tsx +0 -318
  25. package/src/cli/main.ts +0 -29
  26. package/src/cli/setup-selector.tsx +1 -1
  27. package/src/cli/usage.ts +0 -1
  28. package/src/hooks/session-banner.ts +7 -5
  29. package/src/hooks/validate-artifact-path.ts +17 -6
  30. package/src/lib/opencode-plugin-package.ts +7 -28
  31. package/src/lib/orchestrator/adapters/opencode.ts +28 -67
  32. package/src/lib/orchestrator/companion-git-sync.ts +29 -93
  33. package/src/lib/orchestrator/companion-hub.ts +71 -110
  34. package/src/lib/orchestrator/repo-local-registry.ts +0 -21
  35. package/src/lib/orchestrator/types.ts +0 -6
  36. package/src/lib/orchestrator/working-repo-projection.ts +13 -9
  37. package/src/lib/package-paths.ts +32 -0
  38. package/src/lib/prebuilt-workspace.ts +0 -3
  39. package/src/opencode/companion-hooks.ts +15 -10
  40. package/src/runtime/companion-git.ts +327 -84
  41. package/src/runtime/companion-sync.ts +34 -43
  42. package/src/runtime/index.ts +7 -5
  43. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +2 -2
  44. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +25 -0
  45. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +9 -7
  46. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +34 -4
  47. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +2 -2
  48. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +25 -0
  49. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +9 -7
  50. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +34 -4
  51. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +68 -77
  52. package/src/tools/setup/capabilities/graphify.ts +0 -4
  53. package/src/tools/setup/dynamic-plugins/frozen.ts +24 -3
  54. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  55. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  56. package/src/tools/setup/dynamic-plugins/staging.ts +55 -0
  57. package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
  58. package/src/tools/setup/providers/opencode-format.ts +4 -0
  59. package/src/tools/setup/providers/opencode.ts +9 -65
  60. package/src/tools/setup/runtime-documents.ts +8 -9
  61. package/src/cli/commands/plugin/verify.ts +0 -29
@@ -1,320 +1,2 @@
1
- import { Box, Text, render as inkRender } from "ink";
2
- import React, { useEffect } from "react";
3
- import { SelectMenu, type SelectMenuItem } from "../lib/components/select-menu";
4
- import { WizardFooter } from "../lib/components/wizard-footer";
5
- import { WizardHeader } from "../lib/components/wizard-header";
6
-
7
1
  export const LAUNCH_TARGETS = ["claude", "opencode"] as const;
8
2
  export type LaunchTarget = (typeof LAUNCH_TARGETS)[number];
9
-
10
- export interface LaunchPreviewData {
11
- repositoryId: string;
12
- repositoryPath: string;
13
- companionPath: string;
14
- }
15
-
16
- export interface LaunchWizardResult {
17
- target: LaunchTarget;
18
- preview: LaunchPreviewData;
19
- }
20
-
21
- type WizardStep = "target" | "review";
22
- type PreviewState =
23
- | { status: "idle" }
24
- | { status: "loading" }
25
- | { status: "ready"; data: LaunchPreviewData }
26
- | { status: "error"; message: string };
27
-
28
- interface LaunchWizardAppProps {
29
- activeIndex: number;
30
- hasPreselectedTarget: boolean;
31
- items: SelectMenuItem[];
32
- previewState: PreviewState;
33
- selectedTarget: LaunchTarget | null;
34
- step: WizardStep;
35
- totalSteps: number;
36
- onMounted?: () => void;
37
- }
38
-
39
- export interface LaunchWizardOptions {
40
- stdin?: NodeJS.ReadStream;
41
- stdout?: NodeJS.WriteStream;
42
- stderr?: NodeJS.WriteStream;
43
- allowedTargets?: Set<string>;
44
- preselectedTarget?: LaunchTarget;
45
- resolvePreview(target: LaunchTarget): Promise<LaunchPreviewData>;
46
- }
47
-
48
- const launchSelectorDeps = {
49
- render: inkRender,
50
- };
51
-
52
- const TARGET_ITEMS: SelectMenuItem[] = [
53
- { key: "claude", label: "claude" },
54
- { key: "opencode", label: "opencode" },
55
- ];
56
-
57
- function parseLaunchSelectorInput(
58
- input: string,
59
- ): "submit" | "cancel" | "up" | "down" | "back" | null {
60
- if (input === "\r" || input === "\n") return "submit";
61
- if (input === "\x1b[A" || input === "[A") return "up";
62
- if (input === "\x1b[B" || input === "[B") return "down";
63
- if (input === "\x7f" || input === "\b") return "back";
64
- if (input === "\x1b" || input.toLowerCase() === "q") return "cancel";
65
- return null;
66
- }
67
-
68
- function ReviewPanel({
69
- previewState,
70
- selectedTarget,
71
- }: {
72
- previewState: PreviewState;
73
- selectedTarget: LaunchTarget | null;
74
- }) {
75
- if (previewState.status === "loading" || previewState.status === "idle") {
76
- return <Text dimColor>Resolving launch preview…</Text>;
77
- }
78
-
79
- if (previewState.status === "error") {
80
- return <Text color="red">{previewState.message}</Text>;
81
- }
82
-
83
- return (
84
- <Box flexDirection="column">
85
- <Text>Target: {selectedTarget}</Text>
86
- <Text>Repository: {previewState.data.repositoryId}</Text>
87
- <Text dimColor>{previewState.data.repositoryPath}</Text>
88
- <Text>Companion:</Text>
89
- <Text dimColor>{previewState.data.companionPath}</Text>
90
- </Box>
91
- );
92
- }
93
-
94
- function LaunchWizardApp({
95
- activeIndex,
96
- hasPreselectedTarget,
97
- items,
98
- previewState,
99
- selectedTarget,
100
- step,
101
- totalSteps,
102
- onMounted,
103
- }: LaunchWizardAppProps): React.JSX.Element {
104
- useEffect(() => {
105
- onMounted?.();
106
- }, [onMounted]);
107
-
108
- const stepNumber = hasPreselectedTarget ? 0 : step === "target" ? 0 : totalSteps - 1;
109
- const title = step === "target" ? "Select launch target" : "Review launch";
110
- const hint =
111
- step === "target"
112
- ? "↑↓ navigate · enter continue · q cancel"
113
- : previewState.status === "error"
114
- ? "enter retry · backspace previous · q cancel"
115
- : previewState.status === "ready"
116
- ? "enter launch · backspace previous · q cancel"
117
- : "backspace previous · q cancel";
118
-
119
- return (
120
- <Box flexDirection="column" width="100%">
121
- <WizardHeader title={title} step={stepNumber} totalSteps={totalSteps} />
122
- <Box marginTop={1} flexDirection="column">
123
- {step === "target" ? <SelectMenu items={items} activeIndex={activeIndex} /> : null}
124
- {step === "review" ? (
125
- <ReviewPanel previewState={previewState} selectedTarget={selectedTarget} />
126
- ) : null}
127
- </Box>
128
- <WizardFooter hint={hint} />
129
- </Box>
130
- );
131
- }
132
-
133
- export async function runLaunchWizard(
134
- options: LaunchWizardOptions,
135
- ): Promise<LaunchWizardResult | null> {
136
- const stdin = options.stdin ?? process.stdin;
137
- const stdout = options.stdout ?? process.stdout;
138
- const stderr = options.stderr ?? process.stderr;
139
- if (!stdin.isTTY || !stdout.isTTY) {
140
- throw new Error("Launch wizard requires a TTY.");
141
- }
142
-
143
- const allowedTargets = options.allowedTargets;
144
- const preselectedTarget = options.preselectedTarget ?? null;
145
- const items: SelectMenuItem[] = TARGET_ITEMS.map((item) => ({
146
- ...item,
147
- dimColor: allowedTargets ? !allowedTargets.has(item.key) : false,
148
- }));
149
- const firstEnabledIndex = items.findIndex((item) => !item.dimColor);
150
- let activeIndex = preselectedTarget ? 0 : firstEnabledIndex < 0 ? 0 : firstEnabledIndex;
151
- let step: WizardStep = preselectedTarget ? "review" : "target";
152
- let selectedTarget: LaunchTarget | null = preselectedTarget;
153
- let previewState: PreviewState = { status: "idle" };
154
- let previewRequestId = 0;
155
- let finalResult: LaunchWizardResult | null = null;
156
- let resolved = false;
157
- let rendered: ReturnType<typeof launchSelectorDeps.render> | undefined;
158
-
159
- const totalSteps = preselectedTarget ? 1 : 2;
160
-
161
- stdin.resume();
162
- (stdin as NodeJS.ReadStream & { ref?: () => void }).ref?.();
163
- const keepAlive = setInterval(() => {}, 1 << 30);
164
-
165
- const cleanup = () => {
166
- clearInterval(keepAlive);
167
- stdin.off("data", onData);
168
- stdin.pause();
169
- stdin.setRawMode?.(false);
170
- (stdin as NodeJS.ReadStream & { unref?: () => void }).unref?.();
171
- };
172
-
173
- const currentTarget = () => LAUNCH_TARGETS[activeIndex];
174
- const isEnabled = (index: number) => !items[index]?.dimColor;
175
- const nextEnabledIndex = (start: number, direction: 1 | -1): number => {
176
- if (!items.some((item) => !item.dimColor)) return start;
177
- let index = start;
178
- for (let i = 0; i < items.length; i += 1) {
179
- index = (index + direction + items.length) % items.length;
180
- if (isEnabled(index)) return index;
181
- }
182
- return start;
183
- };
184
-
185
- const rerender = () => {
186
- rendered?.rerender(
187
- <LaunchWizardApp
188
- activeIndex={activeIndex}
189
- hasPreselectedTarget={!!preselectedTarget}
190
- items={items}
191
- previewState={previewState}
192
- selectedTarget={selectedTarget}
193
- step={step}
194
- totalSteps={totalSteps}
195
- onMounted={onMounted}
196
- />,
197
- );
198
- };
199
-
200
- const settle = (
201
- value: LaunchWizardResult | null,
202
- resolve: (value: LaunchWizardResult | null) => void,
203
- ) => {
204
- if (resolved) return;
205
- resolved = true;
206
- cleanup();
207
- resolve(value);
208
- };
209
-
210
- const loadPreview = () => {
211
- if (!selectedTarget) return;
212
- const requestId = ++previewRequestId;
213
- previewState = { status: "loading" };
214
- rerender();
215
-
216
- options
217
- .resolvePreview(selectedTarget)
218
- .then((preview) => {
219
- if (requestId !== previewRequestId || resolved) return;
220
- previewState = { status: "ready", data: preview };
221
- rerender();
222
- })
223
- .catch((error: unknown) => {
224
- if (requestId !== previewRequestId || resolved) return;
225
- previewState = {
226
- status: "error",
227
- message: error instanceof Error ? error.message : String(error),
228
- };
229
- rerender();
230
- });
231
- };
232
-
233
- const enterReview = () => {
234
- step = "review";
235
- loadPreview();
236
- };
237
-
238
- const onMounted = () => {
239
- const buffered = stdin.read();
240
- if (buffered) {
241
- // Discard buffered input before the wizard becomes interactive.
242
- }
243
- stdin.on("data", onData);
244
- if (step === "review") {
245
- loadPreview();
246
- }
247
- };
248
-
249
- const onData = (chunk: string) => {
250
- const action = parseLaunchSelectorInput(chunk);
251
- if (!action) return;
252
- if (action === "cancel") {
253
- finalResult = null;
254
- rendered?.unmount();
255
- return;
256
- }
257
- if (action === "back") {
258
- if (step === "review") {
259
- if (preselectedTarget) {
260
- finalResult = null;
261
- rendered?.unmount();
262
- } else {
263
- step = "target";
264
- previewState = { status: "idle" };
265
- rerender();
266
- }
267
- return;
268
- }
269
- return;
270
- }
271
- if (step === "review") {
272
- if (action !== "submit") return;
273
- if (previewState.status === "error") {
274
- loadPreview();
275
- return;
276
- }
277
- if (previewState.status !== "ready" || !selectedTarget) return;
278
- finalResult = {
279
- target: selectedTarget,
280
- preview: previewState.data,
281
- };
282
- rendered?.unmount();
283
- return;
284
- }
285
- if (action === "up" || action === "down") {
286
- activeIndex = nextEnabledIndex(activeIndex, action === "up" ? -1 : 1);
287
- rerender();
288
- return;
289
- }
290
- if (action !== "submit") return;
291
- if (!isEnabled(activeIndex)) return;
292
- selectedTarget = currentTarget();
293
- enterReview();
294
- };
295
-
296
- stdin.setEncoding("utf8");
297
- stdin.setRawMode?.(true);
298
-
299
- return new Promise<LaunchWizardResult | null>((resolve) => {
300
- rendered = launchSelectorDeps.render(
301
- <LaunchWizardApp
302
- activeIndex={activeIndex}
303
- hasPreselectedTarget={!!preselectedTarget}
304
- items={items}
305
- previewState={previewState}
306
- selectedTarget={selectedTarget}
307
- step={step}
308
- totalSteps={totalSteps}
309
- onMounted={onMounted}
310
- />,
311
- { stdin, stdout, stderr },
312
- );
313
-
314
- const finish = (value: LaunchWizardResult | null) => settle(value, resolve);
315
-
316
- rendered.waitUntilExit().then(() => {
317
- finish(finalResult);
318
- });
319
- });
320
- }
package/src/cli/main.ts CHANGED
@@ -200,12 +200,6 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
200
200
  case "prepare":
201
201
  if (!(await gate({ updateGuard: true }))) return;
202
202
  break;
203
- // Hub commands establish and operate on a local hub root directly;
204
- // they must not require a linked working repository or installation —
205
- // but companions are never hubs.
206
- case "hub":
207
- if (!(await gate({ updateGuard: true, notCompanionRoot: true }))) return;
208
- break;
209
203
  // open/tui/sync/update consume a companion context.
210
204
  case "open":
211
205
  case "tui":
@@ -252,29 +246,6 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
252
246
  return;
253
247
  await runLaunchOpenCodeCommand(argv.slice(3), { directPassthrough: true });
254
248
  return;
255
- case "launch": {
256
- const launchArgs = argv.slice(4);
257
- const companion = parseDirectLaunchArgs(launchArgs).scope === "companion";
258
- if (subcommand !== "claude" && subcommand !== "opencode") {
259
- console.error(`${FRAMEWORK_NAME}: expected launch claude or launch opencode.`);
260
- process.exitCode = 1;
261
- return;
262
- }
263
-
264
- const launchCommand =
265
- subcommand === "claude" ? runLaunchClaudeCommand : runLaunchOpenCodeCommand;
266
- if (
267
- !(await gate({
268
- updateGuard: true,
269
- companion: companion ? { persistSelection: false } : true,
270
- install: true,
271
- }))
272
- )
273
- return;
274
-
275
- await launchCommand(launchArgs, { directPassthrough: true });
276
- return;
277
- }
278
249
  case "report":
279
250
  if (!(await gate({ updateGuard: true, install: true }))) return;
280
251
  await runReportCommand(argv.slice(3));
@@ -1,5 +1,5 @@
1
1
  import { Box, Text, render as inkRender } from "ink";
2
- import React, { useEffect } from "react";
2
+ import { useEffect } from "react";
3
3
 
4
4
  import { SelectMenu, type SelectMenuItem } from "../lib/components/select-menu";
5
5
  import { WizardFooter } from "../lib/components/wizard-footer";
package/src/cli/usage.ts CHANGED
@@ -42,7 +42,6 @@ export function usage(): string {
42
42
  ` ${n} cap graphify <subcommand> [args...]`,
43
43
  ` ${n} cap index [--graphify] [--tokensave]`,
44
44
  ` ${n} plugin install <package>[@version]`,
45
- ` ${n} plugin verify`,
46
45
  ...pluginCliCommandLines(),
47
46
  ` ${n} update`,
48
47
  ` ${n} update --check`,
@@ -30,12 +30,12 @@ export const PROJECTED_BANNER_FLAG = "--projected";
30
30
  * With `mate doctor` out of scope this is one of only two surfaces where a
31
31
  * drifted wrap becomes visible, so staleness is reported, never suppressed.
32
32
  */
33
- export function buildBanner(
33
+ export async function buildBanner(
34
34
  env: HookEnv,
35
35
  cwd: string = process.cwd(),
36
36
  argv: readonly string[] = [],
37
37
  syncOptions: UnattendedSyncOptions = {},
38
- ): BannerOutcome {
38
+ ): Promise<BannerOutcome> {
39
39
  if (argv.includes(PROJECTED_BANNER_FLAG) && hasLaunchEnvironment(env)) {
40
40
  return { exitCode: 0, stdout: "" };
41
41
  }
@@ -52,7 +52,9 @@ export function buildBanner(
52
52
  */
53
53
  const gitNotes = hasLaunchEnvironment(env)
54
54
  ? []
55
- : unattendedSyncStalenessLines(syncCompanionUnattended(context.companionPath, syncOptions));
55
+ : unattendedSyncStalenessLines(
56
+ await syncCompanionUnattended(context.companionPath, syncOptions),
57
+ );
56
58
 
57
59
  const lines = [
58
60
  `mate v${env.MATE_VERSION || mateVersion()}`,
@@ -71,8 +73,8 @@ export function buildBanner(
71
73
  }
72
74
 
73
75
  // Plugin-shim entry.
74
- export function run(argv: readonly string[] = process.argv.slice(2)): number {
75
- const outcome = buildBanner(process.env, process.cwd(), argv);
76
+ export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
77
+ const outcome = await buildBanner(process.env, process.cwd(), argv);
76
78
  if (outcome.stdout) process.stdout.write(outcome.stdout);
77
79
  return outcome.exitCode;
78
80
  }
@@ -12,6 +12,7 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
 
15
+ import { gitEnvironment } from "../runtime/companion-git";
15
16
  import { companionForkRefusal } from "../runtime/companion-sync";
16
17
  import { readCompanionRuntimeContext } from "../runtime/env";
17
18
  import { companionLinkPath } from "../runtime/repo-local";
@@ -143,6 +144,7 @@ function isGitignored(target: string, ctx: GuardContext): boolean {
143
144
  const relPath = path.relative(root, target);
144
145
  const result = spawnSync("git", ["-C", root, "check-ignore", "--no-index", "-q", "--", relPath], {
145
146
  stdio: "ignore",
147
+ env: gitEnvironment(),
146
148
  });
147
149
  return result.status === 0;
148
150
  }
@@ -156,6 +158,7 @@ function isGitTracked(target: string, ctx: GuardContext): boolean {
156
158
  const relPath = path.relative(root, target);
157
159
  const result = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", relPath], {
158
160
  stdio: "ignore",
161
+ env: gitEnvironment(),
159
162
  });
160
163
  return result.status === 0;
161
164
  }
@@ -226,11 +229,14 @@ function checkFilePath(
226
229
  * gated on the absence of a launch environment, and the path refusal keeps its
227
230
  * input-only verdict and its double-load tolerance untouched.
228
231
  */
229
- function checkForkedCompanion(filePath: string, ctx: GuardContext): HookOutcome | null {
232
+ async function checkForkedCompanion(
233
+ filePath: string,
234
+ ctx: GuardContext,
235
+ ): Promise<HookOutcome | null> {
230
236
  if (!filePath || !artifactLikePath(filePath)) return null;
231
237
  if (!isCompanionPath(normalizePath(filePath, ctx), ctx)) return null;
232
238
 
233
- const refusal = companionForkRefusal(ctx.env, ctx.companion);
239
+ const refusal = await companionForkRefusal(ctx.env, ctx.companion);
234
240
  return refusal ? { exitCode: 2, stderr: `${refusal}\n` } : null;
235
241
  }
236
242
 
@@ -250,7 +256,11 @@ export function commandMatches(command: string): string[] {
250
256
  * Fails open only when no Mate context resolves from either source; in a
251
257
  * wrapped Working Repository the guard runs without a launch.
252
258
  */
253
- export function evaluate(payload: unknown, env: HookEnv, cwd: string = process.cwd()): HookOutcome {
259
+ export async function evaluate(
260
+ payload: unknown,
261
+ env: HookEnv,
262
+ cwd: string = process.cwd(),
263
+ ): Promise<HookOutcome> {
254
264
  const allow: HookOutcome = { exitCode: 0, stderr: "" };
255
265
 
256
266
  const ctx = guardContext(env, cwd);
@@ -267,7 +277,7 @@ export function evaluate(payload: unknown, env: HookEnv, cwd: string = process.c
267
277
  const filePath = String(toolInput.file_path ?? "");
268
278
  /** The path refusal wins when both apply: its message is not replaced. */
269
279
  const outcome = checkFilePath(filePath, ctx, toolName === "Edit" || toolName === "MultiEdit");
270
- return outcome ?? checkForkedCompanion(filePath, ctx) ?? allow;
280
+ return outcome ?? (await checkForkedCompanion(filePath, ctx)) ?? allow;
271
281
  }
272
282
 
273
283
  if (toolName === "Bash") {
@@ -278,7 +288,8 @@ export function evaluate(payload: unknown, env: HookEnv, cwd: string = process.c
278
288
  if (outcome) return outcome;
279
289
  }
280
290
  for (const target of targets) {
281
- const outcome = checkForkedCompanion(target, ctx);
291
+ // oxlint-disable-next-line no-await-in-loop -- the first refusal wins
292
+ const outcome = await checkForkedCompanion(target, ctx);
282
293
  if (outcome) return outcome;
283
294
  }
284
295
  return allow;
@@ -303,7 +314,7 @@ export async function run(): Promise<number> {
303
314
  } catch {
304
315
  payload = {};
305
316
  }
306
- const outcome = evaluate(payload, process.env);
317
+ const outcome = await evaluate(payload, process.env);
307
318
  if (outcome.stderr) process.stderr.write(outcome.stderr);
308
319
  return outcome.exitCode;
309
320
  }
@@ -5,24 +5,18 @@ import path from "node:path";
5
5
 
6
6
  import { isPreinstalledPluginPath } from "./preinstalled-plugins";
7
7
  import { PUBLIC_NPM_REGISTRY } from "./public-npm";
8
- import { getCurrentVersion } from "./update-checker";
9
8
 
10
- export const OPENCODE_PLUGIN_PACKAGE_NAME = "@uniqbit/mate-opencode-plugin";
9
+ /** The retired plugin package, bundled into mate-core as `opencode-plugin/`. */
10
+ const OPENCODE_PLUGIN_PACKAGE_NAME = "@uniqbit/mate-opencode-plugin";
11
11
 
12
12
  /**
13
- * The plugin package reference configured for OpenCode. All public Mate
14
- * packages are released in lockstep with synchronized versions, so the pinned
15
- * plugin version is derived from the CLI's own version instead of a separate
16
- * compatibility range.
13
+ * Matches a reference to the retired plugin package: the published spec,
14
+ * a versioned spec, or a path bound to an installed copy. Only for stripping
15
+ * entries older releases wrote into committed config.
17
16
  */
18
- export function getOpenCodePluginPackageReference(version: string = getCurrentVersion()): string {
19
- return `${OPENCODE_PLUGIN_PACKAGE_NAME}@${version}`;
20
- }
21
-
22
- /** Matches the published reference and a reference bound to an installed copy alike. */
23
- export function isMateOpenCodePluginReference(value: unknown): boolean {
17
+ export function isLegacyMateOpenCodePluginReference(value: unknown): boolean {
24
18
  if (typeof value === "object" && value !== null && !Array.isArray(value) && "package" in value) {
25
- return isMateOpenCodePluginReference(value.package);
19
+ return isLegacyMateOpenCodePluginReference(value.package);
26
20
  }
27
21
  if (typeof value !== "string") return false;
28
22
  if (
@@ -85,18 +79,3 @@ export async function warmOpenCodePackageCache(
85
79
  return { ok: false, detail: (error as Error).message };
86
80
  }
87
81
  }
88
-
89
- /**
90
- * Pre-fetch the pinned plugin package into OpenCode's npm plugin environment
91
- * (`<cache>/packages/<spec>/`), mirroring OpenCode's own on-demand install
92
- * layout, so the first managed launch after a setup or update does not depend
93
- * on registry access. Best effort: failures are reported, never thrown.
94
- */
95
- export async function warmOpenCodePluginCache(
96
- version: string = getCurrentVersion(),
97
- env: NodeJS.ProcessEnv = process.env,
98
- registry = PUBLIC_NPM_REGISTRY,
99
- ): Promise<WarmPluginCacheResult> {
100
- const reference = getOpenCodePluginPackageReference(version);
101
- return warmOpenCodePackageCache(OPENCODE_PLUGIN_PACKAGE_NAME, reference, env, registry);
102
- }
@@ -7,14 +7,8 @@ const path = await import("node:path");
7
7
  import { MATE_ENV } from "../../../runtime/env";
8
8
  import { validateGuidanceData, type MateGuidanceFile } from "../../../runtime/guidance";
9
9
 
10
+ import { getOpenCodePluginRoot, validateOpenCodePluginAssets } from "../../package-paths";
10
11
  import {
11
- getOpenCodePluginPackageReference,
12
- isMateOpenCodePluginReference,
13
- OPENCODE_PLUGIN_PACKAGE_NAME,
14
- } from "../../opencode-plugin-package";
15
- import { installedVersionAt, isPreinstalledPluginPath } from "../../preinstalled-plugins";
16
- import {
17
- getOpenCodePluginReferences,
18
12
  mergeOpenCodeConfigContent,
19
13
  readOpenCodeConfig,
20
14
  } from "../../../tools/setup/providers/opencode-format";
@@ -86,6 +80,7 @@ export class OpenCodeAdapter extends LaunchAdapter {
86
80
  OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
87
81
  {
88
82
  permissions: renderCompanionExternalDirectoryPermissions(context.companionPath),
83
+ plugins: [getOpenCodePluginRoot()],
89
84
  references: {
90
85
  mate: context.companionPath,
91
86
  },
@@ -130,75 +125,41 @@ export class OpenCodeAdapter extends LaunchAdapter {
130
125
  );
131
126
  }
132
127
 
133
- const expectedPluginReference = getOpenCodePluginPackageReference();
134
- const errors: string[] = [
135
- ...(await this.validatePluginReference(
136
- context,
137
- path.join(".opencode", "opencode.json"),
138
- expectedPluginReference,
139
- )),
128
+ /** Asset errors carry their own reinstall hint; only companion errors are repaired by setup. */
129
+ const assetErrors = this.validatePluginAssets();
130
+ const companionErrors = [
131
+ ...(await this.validateConfigReadable(context)),
140
132
  ...this.validateGuidance(context),
141
133
  ];
142
- if (errors.length > 0) {
143
- throw new LaunchPreflightError(
144
- [
145
- "OpenCode companion runtime is invalid.",
146
- ...errors.map((error) => `- ${error}`),
147
- `Expected Mate plugin package: ${expectedPluginReference}`,
148
- "Repair the companion runtime by re-running `mate companion setup` in the companion repository or `mate opencode` from the working repository.",
149
- ].join("\n"),
134
+ const errors = [...assetErrors, ...companionErrors];
135
+ if (errors.length === 0) return;
136
+
137
+ const lines = [
138
+ "OpenCode companion runtime is invalid.",
139
+ ...errors.map((error) => `- ${error}`),
140
+ ];
141
+ if (companionErrors.length > 0) {
142
+ lines.push(
143
+ "Repair the companion runtime by re-running `mate companion setup` in the companion repository or `mate opencode` from the working repository.",
150
144
  );
151
145
  }
146
+ throw new LaunchPreflightError(lines.join("\n"));
152
147
  }
153
148
 
154
- private async validatePluginReference(
155
- context: AdapterContext,
156
- configFile: string,
157
- expectedPluginReference: string,
158
- ): Promise<string[]> {
159
- const configPath = path.join(context.companionPath, configFile);
160
-
161
- const { present, config } = await readOpenCodeConfig(configPath);
162
- if (!present) {
163
- return [`Unreadable OpenCode configuration: ${configPath}`];
164
- }
165
-
166
- const mateReferences = getOpenCodePluginReferences(config).filter(
167
- isMateOpenCodePluginReference,
168
- ) as string[];
149
+ /** Launch-time sync leaves unparseable user config untouched; OpenCode would fail on it at startup. */
150
+ private async validateConfigReadable(context: AdapterContext): Promise<string[]> {
151
+ const configPath = path.join(context.companionPath, ".opencode", "opencode.json");
152
+ const { present } = await readOpenCodeConfig(configPath);
153
+ return present ? [] : [`Unreadable OpenCode configuration: ${configPath}`];
154
+ }
169
155
 
170
- if (mateReferences.includes(expectedPluginReference)) {
156
+ private validatePluginAssets(): string[] {
157
+ try {
158
+ validateOpenCodePluginAssets();
171
159
  return [];
160
+ } catch (error) {
161
+ return [(error as Error).message];
172
162
  }
173
-
174
- // Setup writes one of two spellings, and both are current: the published
175
- // spec on an ordinary workstation, and an absolute path into the
176
- // machine-local workspace where the distribution supplied an installed
177
- // copy. Comparing only against the spec would refuse the launch on exactly
178
- // the deployments preinstalled binding exists for.
179
- //
180
- // A bound reference carries its version in the package it points at rather
181
- // than in the string, so staleness is read off disk. It is still caught,
182
- // and now names the version actually installed.
183
- const expectedVersion = expectedPluginReference.slice(OPENCODE_PLUGIN_PACKAGE_NAME.length + 1);
184
- const bound = mateReferences.find((reference) =>
185
- isPreinstalledPluginPath(reference, OPENCODE_PLUGIN_PACKAGE_NAME),
186
- );
187
- if (bound !== undefined) {
188
- const version = await installedVersionAt(bound);
189
- if (version === expectedVersion) return [];
190
- return [
191
- `Stale Mate plugin package reference in ${configFile}: it points at ${bound}, which is ${version ?? "not installed"} rather than ${expectedVersion}.`,
192
- ];
193
- }
194
-
195
- if (mateReferences.length > 0) {
196
- return [
197
- `Stale Mate plugin package reference in ${configFile}: found ${mateReferences.join(", ")}.`,
198
- ];
199
- }
200
-
201
- return [`Missing Mate plugin package reference in ${configFile}.`];
202
163
  }
203
164
 
204
165
  // Self-check the guidance payload this launch is about to inject so a