@uniqbit/mate-core 0.15.1-canary.0 → 0.15.1-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.1-canary.0",
3
+ "version": "0.15.1-canary.1",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export { ensureCapabilityEnabled, type EnsureCapabilityEnabledDeps } from "./cli
24
24
  export type {
25
25
  CapabilityPlugin,
26
26
  InstructionsService,
27
+ LaunchPreflightContext,
27
28
  McpServerDescriptor,
28
29
  McpService,
29
30
  Plugin,
@@ -0,0 +1,94 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { PUBLIC_NPM_REGISTRY } from "./public-npm";
6
+
7
+ export const CONTEXT_MODE_PACKAGE_NAME = "context-mode";
8
+ export const CONTEXT_MODE_VERSION = "1.0.169";
9
+ export const CONTEXT_MODE_NODE_REQUIREMENT = ">=22.5.0";
10
+
11
+ export function getContextModePackageReference(): string {
12
+ return `${CONTEXT_MODE_PACKAGE_NAME}@${CONTEXT_MODE_VERSION}`;
13
+ }
14
+
15
+ export function isContextModePackageReference(value: unknown): value is string {
16
+ return (
17
+ typeof value === "string" &&
18
+ (value === CONTEXT_MODE_PACKAGE_NAME || value.startsWith(`${CONTEXT_MODE_PACKAGE_NAME}@`))
19
+ );
20
+ }
21
+
22
+ export function getContextModeInstallDir(companionPath: string): string {
23
+ return path.join(companionPath, ".mate", "dependencies", CONTEXT_MODE_PACKAGE_NAME);
24
+ }
25
+
26
+ export function getContextModePackageRoot(companionPath: string): string {
27
+ return path.join(
28
+ getContextModeInstallDir(companionPath),
29
+ "node_modules",
30
+ CONTEXT_MODE_PACKAGE_NAME,
31
+ );
32
+ }
33
+
34
+ export function isContextModeNodeVersionSupported(version: string): boolean {
35
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
36
+ return major > 22 || (major === 22 && minor >= 5);
37
+ }
38
+
39
+ export function validateContextModeNodeRuntime(): void {
40
+ const result = spawnSync("node", ["--version"], { encoding: "utf8" });
41
+ const version = result.stdout?.trim() ?? "";
42
+ if (result.status !== 0 || !isContextModeNodeVersionSupported(version)) {
43
+ throw new Error(
44
+ `context-mode ${CONTEXT_MODE_VERSION} requires Node.js ${CONTEXT_MODE_NODE_REQUIREMENT}; found ${version || "no usable node executable"}.`,
45
+ );
46
+ }
47
+ }
48
+
49
+ export async function validateContextModePackage(companionPath: string): Promise<void> {
50
+ validateContextModeNodeRuntime();
51
+ const root = getContextModePackageRoot(companionPath);
52
+ const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")) as {
53
+ version?: string;
54
+ };
55
+ if (manifest.version !== CONTEXT_MODE_VERSION) {
56
+ throw new Error(
57
+ `Expected ${getContextModePackageReference()} at ${root}; found ${manifest.version ?? "an unknown version"}. Re-run \`mate companion setup\`.`,
58
+ );
59
+ }
60
+ await Promise.all(
61
+ [
62
+ ".claude-plugin/plugin.json",
63
+ "hooks/hooks.json",
64
+ "skills/context-mode/SKILL.md",
65
+ "build/adapters/opencode/plugin.js",
66
+ ].map((asset) => fs.access(path.join(root, asset))),
67
+ );
68
+ }
69
+
70
+ export async function installContextModePackage(companionPath: string): Promise<void> {
71
+ validateContextModeNodeRuntime();
72
+ const installDir = getContextModeInstallDir(companionPath);
73
+ await fs.mkdir(installDir, { recursive: true });
74
+ await fs.writeFile(
75
+ path.join(installDir, "package.json"),
76
+ JSON.stringify(
77
+ { private: true, dependencies: { [CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_VERSION } },
78
+ null,
79
+ 2,
80
+ ) + "\n",
81
+ "utf8",
82
+ );
83
+ const result = spawnSync(
84
+ "npm",
85
+ ["install", "--no-audit", "--no-fund", "--silent", "--registry", PUBLIC_NPM_REGISTRY],
86
+ { cwd: installDir, encoding: "utf8" },
87
+ );
88
+ if (result.error || result.status !== 0) {
89
+ throw new Error(
90
+ `Could not install ${getContextModePackageReference()}: ${result.error?.message ?? result.stderr?.trim() ?? `npm exited with ${result.status}`}`,
91
+ );
92
+ }
93
+ await validateContextModePackage(companionPath);
94
+ }
@@ -44,53 +44,50 @@ export const opencodePluginCacheDeps = {
44
44
  }),
45
45
  };
46
46
 
47
- /**
48
- * Pre-fetch the pinned plugin package into OpenCode's npm plugin environment
49
- * (`<cache>/packages/<spec>/`), mirroring OpenCode's own on-demand install
50
- * layout, so the first managed launch after a setup or update does not depend
51
- * on registry access. Best effort: failures are reported, never thrown.
52
- */
53
- export async function warmOpenCodePluginCache(
54
- version: string = getCurrentVersion(),
47
+ export async function warmOpenCodePackageCache(
48
+ packageName: string,
49
+ packageReference: string,
55
50
  env: NodeJS.ProcessEnv = process.env,
56
51
  registry = PUBLIC_NPM_REGISTRY,
57
52
  ): Promise<WarmPluginCacheResult> {
58
- // Escape hatch for tests and intentionally offline environments: skip the
59
- // pre-fetch entirely instead of attempting a registry install.
60
53
  if (env.MATE_DISABLE_OPENCODE_PLUGIN_PREFETCH === "1") {
61
54
  return { ok: true, detail: "pre-fetch disabled via MATE_DISABLE_OPENCODE_PLUGIN_PREFETCH" };
62
55
  }
63
-
64
- const reference = getOpenCodePluginPackageReference(version);
65
- const specDir = path.join(getOpenCodeCacheDir(env), "packages", reference);
66
-
56
+ const version = packageReference.slice(packageName.length + 1);
57
+ const specDir = path.join(getOpenCodeCacheDir(env), "packages", packageReference);
67
58
  try {
68
59
  await fs.mkdir(specDir, { recursive: true });
69
60
  await fs.writeFile(
70
61
  path.join(specDir, "package.json"),
71
- JSON.stringify({ dependencies: { [OPENCODE_PLUGIN_PACKAGE_NAME]: version } }, null, 2) + "\n",
62
+ JSON.stringify({ dependencies: { [packageName]: version } }, null, 2) + "\n",
72
63
  "utf8",
73
64
  );
74
-
75
65
  const result = opencodePluginCacheDeps.runInstall(specDir, registry);
76
- if (result.error) {
77
- return { ok: false, detail: result.error.message };
78
- }
66
+ if (result.error) return { ok: false, detail: result.error.message };
79
67
  if (result.status !== 0) {
80
- const output = `${result.stderr ?? ""}`.trim() || `npm install exited with ${result.status}`;
81
- return { ok: false, detail: output };
68
+ return {
69
+ ok: false,
70
+ detail: `${result.stderr ?? ""}`.trim() || `npm install exited with ${result.status}`,
71
+ };
82
72
  }
83
-
84
- await fs.access(
85
- path.join(
86
- specDir,
87
- "node_modules",
88
- ...OPENCODE_PLUGIN_PACKAGE_NAME.split("/"),
89
- "package.json",
90
- ),
91
- );
73
+ await fs.access(path.join(specDir, "node_modules", ...packageName.split("/"), "package.json"));
92
74
  return { ok: true };
93
75
  } catch (error) {
94
76
  return { ok: false, detail: (error as Error).message };
95
77
  }
96
78
  }
79
+
80
+ /**
81
+ * Pre-fetch the pinned plugin package into OpenCode's npm plugin environment
82
+ * (`<cache>/packages/<spec>/`), mirroring OpenCode's own on-demand install
83
+ * layout, so the first managed launch after a setup or update does not depend
84
+ * on registry access. Best effort: failures are reported, never thrown.
85
+ */
86
+ export async function warmOpenCodePluginCache(
87
+ version: string = getCurrentVersion(),
88
+ env: NodeJS.ProcessEnv = process.env,
89
+ registry = PUBLIC_NPM_REGISTRY,
90
+ ): Promise<WarmPluginCacheResult> {
91
+ const reference = getOpenCodePluginPackageReference(version);
92
+ return warmOpenCodePackageCache(OPENCODE_PLUGIN_PACKAGE_NAME, reference, env, registry);
93
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
 
3
3
  import { buildCompanionGuidance } from "../../../playbooks/companion-guidance";
4
+ import { getContextModePackageRoot, validateContextModePackage } from "../../context-mode-package";
4
5
  import {
5
6
  getCompanionClaudeMcpConfigPath,
6
7
  getCompanionClaudeSettingsPath,
@@ -11,6 +12,19 @@ export class ClaudeAdapter extends LaunchAdapter {
11
12
  readonly toolName = "claude";
12
13
  readonly interactive = true;
13
14
 
15
+ async validateLaunch(context: AdapterContext): Promise<void> {
16
+ if (context.capabilities.some((capability) => capability.name === "context-mode")) {
17
+ try {
18
+ await validateContextModePackage(context.companionPath);
19
+ } catch (error) {
20
+ throw new Error(
21
+ `Claude context-mode plugin is unavailable: ${(error as Error).message} Repair it with \`mate companion setup\`.`,
22
+ { cause: error },
23
+ );
24
+ }
25
+ }
26
+ }
27
+
14
28
  buildArgs(context: AdapterContext, args: string[]): string[] {
15
29
  // Load Mate-managed Claude config from companion settings when present,
16
30
  // layered on top of the user's user/project/local sources so global
@@ -29,6 +43,11 @@ export class ClaudeAdapter extends LaunchAdapter {
29
43
  const mcpConfigArgs = existsSync(companionMcpConfigPath)
30
44
  ? ["--mcp-config", companionMcpConfigPath]
31
45
  : [];
46
+ const contextModeArgs = context.capabilities.some(
47
+ (capability) => capability.name === "context-mode",
48
+ )
49
+ ? ["--plugin-dir", getContextModePackageRoot(context.companionPath)]
50
+ : [];
32
51
 
33
52
  return [
34
53
  "--add-dir",
@@ -37,6 +56,7 @@ export class ClaudeAdapter extends LaunchAdapter {
37
56
  buildCompanionGuidance(context),
38
57
  ...settingsArgs,
39
58
  ...mcpConfigArgs,
59
+ ...contextModeArgs,
40
60
  ...args,
41
61
  ];
42
62
  }
@@ -171,7 +171,6 @@ export class OpenCodeAdapter extends LaunchAdapter {
171
171
  )),
172
172
  ...this.validateGuidance(context),
173
173
  ];
174
-
175
174
  if (errors.length > 0) {
176
175
  throw new LaunchPreflightError(
177
176
  [
@@ -197,9 +196,11 @@ export class OpenCodeAdapter extends LaunchAdapter {
197
196
  } catch {
198
197
  return [`Unreadable OpenCode configuration: ${configPath}`];
199
198
  }
199
+ if (!isRecord(config)) {
200
+ return [`Unreadable OpenCode configuration: ${configPath}`];
201
+ }
200
202
 
201
- const plugins =
202
- isRecord(config) && Array.isArray(config.plugin) ? (config.plugin as unknown[]) : [];
203
+ const plugins = Array.isArray(config.plugin) ? (config.plugin as unknown[]) : [];
203
204
  const mateReferences = plugins.filter(isMateOpenCodePluginReference) as string[];
204
205
 
205
206
  if (mateReferences.includes(expectedPluginReference)) {
@@ -1,5 +1,7 @@
1
1
  // oxlint-disable no-underscore-dangle
2
2
  import { syncCompanionFiles, syncWorkingRepoClaudeSettings } from "../../tools/setup";
3
+ import { getActiveDistribution } from "../../distribution";
4
+ import type { CapabilityPlugin, LaunchPreflightContext } from "../../tools/setup/plugin";
3
5
  import type { LaunchAdapter, AdapterContext } from "./adapters/base";
4
6
  import { ClaudeAdapter } from "./adapters/claude";
5
7
  import { OpenCodeAdapter } from "./adapters/opencode";
@@ -8,6 +10,7 @@ import { syncCompanionGit } from "./companion-git-sync";
8
10
  import { resolveForLaunch, type LaunchContext } from "./framework-context";
9
11
  import {
10
12
  RepositoryNotSelectedError,
13
+ LaunchPreflightError,
11
14
  ToolNotAllowedError,
12
15
  type FrameworkConfig,
13
16
  type LaunchRequest,
@@ -70,6 +73,7 @@ export class FrameworkLauncher {
70
73
  state.companionPath,
71
74
  state.config,
72
75
  );
76
+ await this.runCapabilityPreflight(state, request.tool);
73
77
  await state.adapter.validateLaunch(adapterContext);
74
78
 
75
79
  return {
@@ -127,6 +131,54 @@ export class FrameworkLauncher {
127
131
  };
128
132
  }
129
133
 
134
+ private async runCapabilityPreflight(
135
+ state: ResolvedLaunchState,
136
+ providerId: string,
137
+ ): Promise<void> {
138
+ const context: LaunchPreflightContext = {
139
+ companionPath: state.companionPath,
140
+ config: state.config,
141
+ repository: state.repository,
142
+ providerId,
143
+ };
144
+ const preflights = getActiveDistribution()
145
+ .registry.getAll()
146
+ .reduce<
147
+ Array<{
148
+ capability: CapabilityPlugin;
149
+ preflight: NonNullable<NonNullable<CapabilityPlugin["forProvider"]>[string]["preflight"]>;
150
+ }>
151
+ >((entries, plugin) => {
152
+ if (plugin.kind !== "capability" || !plugin.isEnabled(state.config)) return entries;
153
+ const capability = plugin as CapabilityPlugin;
154
+ const preflight = capability.forProvider?.[providerId]?.preflight;
155
+ if (preflight) entries.push({ capability, preflight });
156
+ return entries;
157
+ }, []);
158
+ const results = await Promise.all(
159
+ preflights.map(async ({ capability, preflight }) => {
160
+ try {
161
+ return await preflight(context);
162
+ } catch (error) {
163
+ const detail = error instanceof Error ? error.message : String(error);
164
+ throw new LaunchPreflightError(
165
+ `Capability preflight failed for ${capability.id} on ${providerId}: ${detail}`,
166
+ { cause: error },
167
+ );
168
+ }
169
+ }),
170
+ );
171
+ const diagnostics = results.flat();
172
+
173
+ if (diagnostics.length > 0) {
174
+ throw new LaunchPreflightError(
175
+ ["Capability launch preflight failed.", ...diagnostics.map((item) => `- ${item}`)].join(
176
+ "\n",
177
+ ),
178
+ );
179
+ }
180
+ }
181
+
130
182
  private async resolveConfig(): Promise<LaunchContext> {
131
183
  return resolveForLaunch(process.cwd());
132
184
  }
package/src/plugins.ts CHANGED
@@ -14,4 +14,5 @@ export { createTokensavePlugin } from "./tools/setup/capabilities/tokensave";
14
14
  export { createHeadroomPlugin } from "./tools/setup/capabilities/headroom";
15
15
  export { createRtkPlugin } from "./tools/setup/capabilities/rtk";
16
16
  export { createGraphifyPlugin } from "./tools/setup/capabilities/graphify";
17
+ export { createContextModePlugin } from "./tools/setup/capabilities/context-mode";
17
18
  export { createGitignorePlugin } from "./tools/setup/plugins/gitignore";
@@ -39,6 +39,23 @@ function shellSplit(command) {
39
39
  quote = null;
40
40
  continue;
41
41
  }
42
+ const next = command[index + 1] || "";
43
+ const previous = command[index - 1] || "";
44
+ const isControlOperator = !quote && (
45
+ char === ";" || char === "|" ||
46
+ (char === "&" && next !== ">" && previous !== ">" && previous !== "<")
47
+ );
48
+ if (isControlOperator) {
49
+ if (current) parts.push(current);
50
+ current = "";
51
+ if ((char === "|" || char === "&") && next === char) {
52
+ parts.push(char + next);
53
+ index += 1;
54
+ } else {
55
+ parts.push(char);
56
+ }
57
+ continue;
58
+ }
42
59
  if (!quote && /\s/.test(char)) {
43
60
  if (current) parts.push(current);
44
61
  current = "";
@@ -55,6 +72,13 @@ function extractChangeFromPath(value) {
55
72
  return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
56
73
  }
57
74
 
75
+ // Shell syntax tokens are never change-name positionals: separators end the
76
+ // archive invocation, redirections are skipped (a bare operator also consumes
77
+ // its target token).
78
+ const separatorPattern = /^(\|\|?|&&?|;)$/;
79
+ const redirectionPattern = /^(\d*(>>?|<)|&>>?)/;
80
+ const bareRedirectionPattern = /^(\d*(>>?|<)|&>>?)$/;
81
+
58
82
  function extractArchiveCommand(command) {
59
83
  const parts = shellSplit(command);
60
84
  const index = parts.findIndex((part, position) =>
@@ -64,6 +88,11 @@ function extractArchiveCommand(command) {
64
88
  for (let position = index + 1; position < parts.length; position += 1) {
65
89
  const part = parts[position];
66
90
  if (!part || part === "--") continue;
91
+ if (separatorPattern.test(part)) return null;
92
+ if (redirectionPattern.test(part)) {
93
+ if (bareRedirectionPattern.test(part)) position += 1;
94
+ continue;
95
+ }
67
96
  if (part.startsWith("-")) {
68
97
  if (part === "--store") position += 1;
69
98
  continue;
@@ -93,27 +122,38 @@ function extractMoveCommand(command) {
93
122
  return null;
94
123
  }
95
124
 
125
+ // Nudge only on apparent success: stay silent on a clear failure indication,
126
+ // nudge when success is indicated or indeterminate.
127
+ function isClearFailure(response) {
128
+ if (!response || typeof response !== "object") return false;
129
+ if (response.success === false || response.is_error === true) return true;
130
+ if (typeof response.error === "string" && response.error) return true;
131
+ if (response.interrupted === true) return true;
132
+ const exitCode = response.exit_code ?? response.exitCode;
133
+ return typeof exitCode === "number" && exitCode !== 0;
134
+ }
135
+
96
136
  let input = {};
97
137
  try {
98
138
  input = JSON.parse(fs.readFileSync(inputFile, "utf8") || "{}");
99
139
  } catch {}
100
140
 
101
- if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") process.exit(0);
141
+ if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") process.exit(0);
102
142
  const command = input.tool_input && typeof input.tool_input.command === "string"
103
143
  ? input.tool_input.command
104
144
  : "";
105
145
  const change = extractArchiveCommand(command) || extractMoveCommand(command);
106
146
  if (!change) process.exit(0);
147
+ if (isClearFailure(input.tool_response)) process.exit(0);
107
148
 
108
- const reason =
109
- "Standalone archival for OpenSpec change " + change + " is blocked. Invoke the " +
110
- "mate-artifact-finish skill, then run `mate artifact finish \"" + change + "\" --json`. " +
111
- "The finish command performs the normal OpenSpec archive and completes commit, tag, and push.";
149
+ const context =
150
+ "OpenSpec change " + change + " was just archived. Invoke the mate-artifact-finish " +
151
+ "skill, then run `mate artifact finish \"" + change + "\" --json` to complete the " +
152
+ "finish workflow (commit, tag, and push).";
112
153
  process.stdout.write(JSON.stringify({
113
154
  hookSpecificOutput: {
114
- hookEventName: "PreToolUse",
115
- permissionDecision: "deny",
116
- permissionDecisionReason: reason,
155
+ hookEventName: "PostToolUse",
156
+ additionalContext: context,
117
157
  },
118
158
  }));
119
159
  ' "$input_file"
@@ -0,0 +1,197 @@
1
+ // oxlint-disable no-await-in-loop -- config files are reconciled sequentially with shared ownership state
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ CONTEXT_MODE_PACKAGE_NAME,
7
+ getContextModeInstallDir,
8
+ getContextModePackageReference,
9
+ installContextModePackage,
10
+ isContextModePackageReference,
11
+ validateContextModePackage,
12
+ } from "../../../lib/context-mode-package";
13
+ import { warmOpenCodePackageCache } from "../../../lib/opencode-plugin-package";
14
+ import type { CapabilityPlugin, LaunchPreflightContext, SetupContext } from "../plugin";
15
+ import { pruneEmptyAncestors } from "../utils";
16
+
17
+ type Config = Record<string, unknown>;
18
+ interface ContextModePluginDeps {
19
+ installPackage?: typeof installContextModePackage;
20
+ validatePackage?: typeof validateContextModePackage;
21
+ warmOpenCodePackage?: typeof warmOpenCodePackageCache;
22
+ }
23
+ interface OwnershipState {
24
+ opencodeConfigs: string[];
25
+ }
26
+
27
+ export const CONTEXT_MODE_OPENCODE_GUIDANCE = `## Context Mode
28
+
29
+ Use Context Mode tools for commands or tool calls whose output may be large, for sandboxed analysis of large files or structured data, and when indexing large fetched content.
30
+
31
+ Canonical Mate policy and any more specific tool-routing instructions remain authoritative. In particular, follow explicit requirements to use TokenSave for codebase exploration, Context7 for library documentation, and specialized tools for their assigned operations; Context Mode complements rather than replaces them.`;
32
+
33
+ function getOwnershipPath(companionPath: string): string {
34
+ return path.join(companionPath, ".mate", "state", "context-mode.json");
35
+ }
36
+
37
+ async function readOwnership(companionPath: string): Promise<OwnershipState> {
38
+ try {
39
+ return JSON.parse(await fs.readFile(getOwnershipPath(companionPath), "utf8")) as OwnershipState;
40
+ } catch {
41
+ return { opencodeConfigs: [] };
42
+ }
43
+ }
44
+
45
+ async function writeOwnership(companionPath: string, state: OwnershipState): Promise<void> {
46
+ const statePath = getOwnershipPath(companionPath);
47
+ if (state.opencodeConfigs.length === 0) {
48
+ await fs.rm(statePath, { force: true });
49
+ return;
50
+ }
51
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
52
+ await fs.writeFile(statePath, JSON.stringify(state, null, 2) + "\n", "utf8");
53
+ }
54
+
55
+ function isRecord(value: unknown): value is Config {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+
59
+ async function readConfig(filePath: string): Promise<Config> {
60
+ try {
61
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
62
+ return isRecord(parsed) ? parsed : {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ function hasContextModeMcp(config: Config): boolean {
69
+ const servers = isRecord(config.mcpServers)
70
+ ? config.mcpServers
71
+ : isRecord(config.mcp)
72
+ ? config.mcp
73
+ : {};
74
+ return Object.entries(servers).some(
75
+ ([name, value]) =>
76
+ name.toLowerCase().includes("context-mode") || JSON.stringify(value).includes("context-mode"),
77
+ );
78
+ }
79
+
80
+ async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "opencode") {
81
+ const configPath =
82
+ provider === "claude"
83
+ ? path.join(ctx.companionPath, ".mcp.json")
84
+ : path.join(ctx.companionPath, ".opencode", "opencode.json");
85
+ if (hasContextModeMcp(await readConfig(configPath))) {
86
+ throw new Error(
87
+ `Cannot enable context-mode for ${provider}: ${configPath} already contains a context-mode MCP registration. Remove the duplicate registration or deselect the context-mode capability; Mate did not modify it.`,
88
+ );
89
+ }
90
+ }
91
+
92
+ async function updateOpenCodeConfig(ctx: SetupContext, enabled: boolean): Promise<void> {
93
+ const reference = getContextModePackageReference();
94
+ const ownership = await readOwnership(ctx.companionPath);
95
+ for (const name of ["opencode.json", "tui.json"]) {
96
+ const configPath = path.join(ctx.companionPath, ".opencode", name);
97
+ const owned = ownership.opencodeConfigs.includes(name);
98
+ if (!enabled && !owned) continue;
99
+ const config = await readConfig(configPath);
100
+ const plugins = Array.isArray(config.plugin) ? config.plugin : [];
101
+ const contextModeReferences = plugins.filter(isContextModePackageReference);
102
+ if (enabled && contextModeReferences.some((entry) => entry !== reference)) {
103
+ throw new Error(
104
+ `Cannot enable ${reference}: ${configPath} contains user-owned context-mode reference ${contextModeReferences.join(", ")}. Mate did not modify it.`,
105
+ );
106
+ }
107
+ const preserved = plugins.filter((entry) => !(entry === reference && owned));
108
+ if (enabled) {
109
+ if (!contextModeReferences.includes(reference)) {
110
+ config.plugin = [...plugins, reference];
111
+ ownership.opencodeConfigs.push(name);
112
+ }
113
+ } else if (preserved.length > 0) {
114
+ config.plugin = preserved;
115
+ } else {
116
+ delete config.plugin;
117
+ }
118
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
119
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
120
+ }
121
+ if (!enabled) ownership.opencodeConfigs = [];
122
+ await writeOwnership(ctx.companionPath, ownership);
123
+ }
124
+
125
+ async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
126
+ const expected = getContextModePackageReference();
127
+ const diagnostics: string[] = [];
128
+ for (const name of ["opencode.json", "tui.json"]) {
129
+ const configPath = path.join(ctx.companionPath, ".opencode", name);
130
+ const config = await readConfig(configPath);
131
+ const plugins = Array.isArray(config.plugin) ? config.plugin : [];
132
+ if (!plugins.includes(expected)) {
133
+ diagnostics.push(
134
+ `Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
135
+ );
136
+ }
137
+ }
138
+ return diagnostics;
139
+ }
140
+
141
+ export function createContextModePlugin(deps: ContextModePluginDeps = {}): CapabilityPlugin {
142
+ const installPackage = deps.installPackage ?? installContextModePackage;
143
+ const validatePackage = deps.validatePackage ?? validateContextModePackage;
144
+ const warmOpenCodePackage = deps.warmOpenCodePackage ?? warmOpenCodePackageCache;
145
+ return {
146
+ id: "context-mode",
147
+ kind: "capability",
148
+ label: "Context Mode",
149
+ description: "Protect context windows with pinned native Claude and OpenCode plugins.",
150
+ defaultSelected: false,
151
+ isEnabled: (config) =>
152
+ (config.capabilities ?? []).some((capability) => capability.name === "context-mode"),
153
+ async apply(ctx) {
154
+ if (ctx.mode === "setup") {
155
+ await installPackage(ctx.companionPath);
156
+ } else {
157
+ await validatePackage(ctx.companionPath);
158
+ }
159
+ await ctx.instructions?.append(CONTEXT_MODE_OPENCODE_GUIDANCE, {
160
+ providers: ["opencode"],
161
+ });
162
+ },
163
+ async teardown(ctx) {
164
+ await fs.rm(getContextModeInstallDir(ctx.companionPath), { recursive: true, force: true });
165
+ await pruneEmptyAncestors(getContextModeInstallDir(ctx.companionPath), ctx.companionPath);
166
+ },
167
+ forProvider: {
168
+ claude: {
169
+ async apply(ctx) {
170
+ await assertNoMcpConflict(ctx, "claude");
171
+ },
172
+ async teardown() {},
173
+ },
174
+ opencode: {
175
+ preflight: validateOpenCodeReferences,
176
+ async apply(ctx) {
177
+ await assertNoMcpConflict(ctx, "opencode");
178
+ await updateOpenCodeConfig(ctx, true);
179
+ if (ctx.mode === "setup") {
180
+ const result = await warmOpenCodePackage(
181
+ CONTEXT_MODE_PACKAGE_NAME,
182
+ getContextModePackageReference(),
183
+ );
184
+ if (!result.ok) {
185
+ process.stderr.write(
186
+ `mate: could not pre-fetch ${getContextModePackageReference()} for OpenCode: ${result.detail ?? "unknown error"}\n`,
187
+ );
188
+ }
189
+ }
190
+ },
191
+ async teardown(ctx) {
192
+ await updateOpenCodeConfig(ctx, false);
193
+ },
194
+ },
195
+ },
196
+ };
197
+ }
@@ -234,8 +234,12 @@ export class ContextServiceMediator {
234
234
  };
235
235
 
236
236
  const instructions: InstructionsService = {
237
- append: async (content: string) => {
238
- const hosts = hostingProviders.filter((provider) => provider.hosting.instructions);
237
+ append: async (content: string, options) => {
238
+ const hosts = hostingProviders.filter(
239
+ (provider) =>
240
+ provider.hosting.instructions &&
241
+ (options?.providers === undefined || options.providers.includes(provider.id)),
242
+ );
239
243
  if (hosts.length === 0) {
240
244
  warn(`${pluginId}: no active provider hosts instruction injection; skipping append`);
241
245
  return;
@@ -1,4 +1,4 @@
1
- import type { FrameworkConfig } from "../../lib/orchestrator/types";
1
+ import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/types";
2
2
  import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
3
3
 
4
4
  export interface SetupContext {
@@ -18,6 +18,13 @@ export interface SetupContext {
18
18
  templates?: TemplatesService;
19
19
  }
20
20
 
21
+ export interface LaunchPreflightContext {
22
+ companionPath: string;
23
+ config: FrameworkConfig;
24
+ repository: LinkedRepository;
25
+ providerId: string;
26
+ }
27
+
21
28
  /** Provider-agnostic description of an MCP server to register. */
22
29
  export interface McpServerDescriptor {
23
30
  name: string;
@@ -32,7 +39,7 @@ export interface McpService {
32
39
  }
33
40
 
34
41
  export interface InstructionsService {
35
- append(content: string): Promise<void>;
42
+ append(content: string, options?: { providers?: string[] }): Promise<void>;
36
43
  }
37
44
 
38
45
  export interface TemplatesService {
@@ -113,6 +120,7 @@ export interface CapabilityPlugin extends Plugin {
113
120
  {
114
121
  apply(ctx: SetupContext): Promise<void>;
115
122
  teardown(ctx: SetupContext): Promise<void>;
123
+ preflight?(ctx: LaunchPreflightContext): Promise<string[]>;
116
124
  }
117
125
  >;
118
126
  }
@@ -267,7 +267,7 @@ function buildManagedClaudeSettings(
267
267
  ...(hooks.SessionStart ?? []),
268
268
  ];
269
269
  if (openspecEnabled) {
270
- hooks.PreToolUse = [
270
+ hooks.PostToolUse = [
271
271
  {
272
272
  matcher: "Bash",
273
273
  hooks: [
@@ -277,7 +277,7 @@ function buildManagedClaudeSettings(
277
277
  },
278
278
  ],
279
279
  },
280
- ...(hooks.PreToolUse ?? []),
280
+ ...(hooks.PostToolUse ?? []),
281
281
  ];
282
282
  }
283
283
  if (reactDoctorEnabled) {