@uniqbit/mate-core 0.17.0-canary.9 → 0.17.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.
Files changed (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -18,6 +18,7 @@ import type {
18
18
  RuntimeContributionsByRuntime,
19
19
  SetupContext,
20
20
  } from "../plugin";
21
+ import type { InstallRequirement } from "../install-contract";
21
22
  import { readClaudeMcpConfig } from "../providers/claude-format";
22
23
  import { getOpenCodePluginReferences, readOpenCodeConfig } from "../providers/opencode-format";
23
24
  import { pruneEmptyAncestors } from "../utils";
@@ -84,32 +85,27 @@ async function assertNoMcpConflict(ctx: SetupContext, provider: "claude" | "open
84
85
  */
85
86
  async function validateOpenCodeReferences(ctx: LaunchPreflightContext): Promise<string[]> {
86
87
  const expected = getContextModePackageReference();
87
- const diagnostics: string[] = [];
88
- for (const name of ["opencode.json", "tui.json"]) {
89
- const configPath = path.join(ctx.companionPath, ".opencode", name);
90
- const { config } = await readOpenCodeConfig(configPath);
91
- const references = getOpenCodePluginReferences(config);
92
- if (references.includes(expected)) continue;
88
+ const configPath = path.join(ctx.companionPath, ".opencode", "opencode.json");
89
+ const { config } = await readOpenCodeConfig(configPath);
90
+ const references = getOpenCodePluginReferences(config);
91
+ if (references.includes(expected)) return [];
93
92
 
94
- const bound = references.find(
95
- (reference): reference is string =>
96
- typeof reference === "string" &&
97
- isPreinstalledPluginPath(reference, CONTEXT_MODE_PACKAGE_NAME),
98
- );
99
- if (bound !== undefined) {
100
- const version = await installedVersionAt(bound);
101
- if (version === CONTEXT_MODE_VERSION) continue;
102
- diagnostics.push(
103
- `Stale context-mode package reference in ${configPath}; it points at ${bound}, which is ${version ?? "not installed"} rather than ${CONTEXT_MODE_VERSION}.`,
104
- );
105
- continue;
106
- }
107
-
108
- diagnostics.push(
109
- `Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
110
- );
93
+ const bound = references.find(
94
+ (reference): reference is string =>
95
+ typeof reference === "string" &&
96
+ isPreinstalledPluginPath(reference, CONTEXT_MODE_PACKAGE_NAME),
97
+ );
98
+ if (bound !== undefined) {
99
+ const version = await installedVersionAt(bound);
100
+ if (version === CONTEXT_MODE_VERSION) return [];
101
+ return [
102
+ `Stale context-mode package reference in ${configPath}; it points at ${bound}, which is ${version ?? "not installed"} rather than ${CONTEXT_MODE_VERSION}.`,
103
+ ];
111
104
  }
112
- return diagnostics;
105
+
106
+ return [
107
+ `Missing or stale context-mode package reference in ${configPath}; expected ${expected}.`,
108
+ ];
113
109
  }
114
110
 
115
111
  export function createContextModePlugin(deps: ContextModePluginDeps = {}): CapabilityPlugin {
@@ -144,7 +140,7 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
144
140
  {
145
141
  reference: getContextModePackageReference(),
146
142
  isManagedReference: isContextModePackageReference,
147
- configFiles: ["opencode.json", "tui.json"],
143
+ configFiles: ["opencode.json"],
148
144
  preinstalled: {
149
145
  packageName: CONTEXT_MODE_PACKAGE_NAME,
150
146
  version: CONTEXT_MODE_VERSION,
@@ -154,6 +150,30 @@ export function createContextModePlugin(deps: ContextModePluginDeps = {}): Capab
154
150
  },
155
151
  };
156
152
  },
153
+ /**
154
+ * The machine-local workspace is gitignored, so a fresh checkout has none;
155
+ * `mate install` provisions it here because sync-mode apply only validates.
156
+ */
157
+ getInstallRequirements: ({ companionPath }): InstallRequirement[] => {
158
+ if (!companionPath) return [];
159
+ const command = `npm install ${getContextModePackageReference()} (in .mate/plugins/.local)`;
160
+ return [
161
+ {
162
+ id: "capability:context-mode",
163
+ label: "Context Mode package",
164
+ group: "companion",
165
+ source: "Context Mode capability",
166
+ command,
167
+ fingerprint: `context-mode:${command}`,
168
+ detect: () =>
169
+ validatePackage(companionPath).then(
170
+ () => true,
171
+ () => false,
172
+ ),
173
+ install: () => installPackage(companionPath),
174
+ },
175
+ ];
176
+ },
157
177
  async apply(ctx) {
158
178
  const legacyInstallDir = getLegacyInstallDir(ctx.companionPath);
159
179
  await fs.rm(legacyInstallDir, { recursive: true, force: true });
@@ -9,10 +9,10 @@ import {
9
9
  // guidance plugin can consume it without an import cycle.
10
10
 
11
11
  // Marker names derive from the framework identity, never the invocation name.
12
- export const GRAPHIFY_START = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:START -->`;
13
- export const GRAPHIFY_END = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:END -->`;
12
+ const GRAPHIFY_START = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:START -->`;
13
+ const GRAPHIFY_END = () => `<!-- ${FRAMEWORK_NAME.toUpperCase()}:GRAPHIFY:END -->`;
14
14
 
15
- export function isGraphifyHeading(line: string): boolean {
15
+ function isGraphifyHeading(line: string): boolean {
16
16
  return /^#{1,6}\s+graphify\s*$/i.test(line);
17
17
  }
18
18
 
@@ -33,7 +33,7 @@ import {
33
33
  export const GRAPHIFY_STORE_SEGMENT = ".graphify";
34
34
 
35
35
  /** Skill tree the external `graphify install` CLI writes into a runtime directory. */
36
- export const GRAPHIFY_SKILL_NAME = "graphify";
36
+ const GRAPHIFY_SKILL_NAME = "graphify";
37
37
  export const GRAPHIFY_OUTPUT_SUBDIR = "graphify-out";
38
38
 
39
39
  const GRAPHIFY_INSTALL_CMD = `uv tool install graphifyy`;
@@ -62,7 +62,7 @@ const GRAPHIFY_PROVIDER_DIRS: Record<string, string> = {
62
62
  export const GRAPHIFY_COMPANION_OUT_PREFIX =
63
63
  "$MATE_ARTIFACT_PATH/.graphify/$MATE_REPO_ID/graphify-out/";
64
64
 
65
- export const GRAPHIFY_SUPPORTED_PROVIDERS = Object.keys(GRAPHIFY_PROVIDER_DIRS);
65
+ const GRAPHIFY_SUPPORTED_PROVIDERS = Object.keys(GRAPHIFY_PROVIDER_DIRS);
66
66
 
67
67
  const CLAUDE_MANAGED_HOOK_COMMAND_SUFFIXES = new Set([
68
68
  "/.claude/hooks/validate-artifact-path",
@@ -21,7 +21,7 @@ import {
21
21
  } from "../utils";
22
22
  import { applyMateSkills, teardownMateSkills } from "../mate";
23
23
 
24
- export { MATE_ARTIFACT_SKILLS, MATE_SKILLS, deployMateSkillDir } from "../mate";
24
+ export { MATE_ARTIFACT_SKILLS, MATE_SKILLS } from "../mate";
25
25
 
26
26
  const execFileAsync = promisify(execFileCb);
27
27
 
@@ -108,10 +108,7 @@ function getMateConventionsPath(companionPath: string): string {
108
108
  return path.join(companionPath, "openspec", "mate-conventions.yaml");
109
109
  }
110
110
 
111
- export async function teardownOpenspecSkills(
112
- skillsDir: string,
113
- companionPath: string,
114
- ): Promise<void> {
111
+ async function teardownOpenspecSkills(skillsDir: string, companionPath: string): Promise<void> {
115
112
  for (const skill of OPENSPEC_SKILLS) {
116
113
  try {
117
114
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
@@ -422,5 +419,3 @@ export function createOpenspecPlugin(deps: OpenSpecPluginDeps = {}): CapabilityP
422
419
  },
423
420
  };
424
421
  }
425
-
426
- export const openspecPlugin = createOpenspecPlugin();
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import type { CapabilityPlugin, RuntimeContributionsByRuntime, SetupContext } from "../plugin";
5
5
  import { pruneEmptyAncestors } from "../utils";
6
6
 
7
- export const SKILL_SRC = path.join(
7
+ const SKILL_SRC = path.join(
8
8
  import.meta.dirname,
9
9
  "../../../templates/capabilities/react-doctor/skill",
10
10
  );
@@ -3,8 +3,8 @@ import type { InstallRequirement } from "../install-contract";
3
3
  import { isCommandOnPath, runShellCommand, runShellCommandSilently } from "../utils";
4
4
  import { confirm } from "../../../cli/confirm";
5
5
 
6
- export const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
7
- export const RTK_INSTALL_CMD_FALLBACK =
6
+ const RTK_INSTALL_CMD_BREW = "brew install rtk-ai/tap/rtk";
7
+ const RTK_INSTALL_CMD_FALLBACK =
8
8
  "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh";
9
9
 
10
10
  const RTK_INIT_COMMANDS: Record<string, string> = {
@@ -1,9 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
 
6
7
  import { resolveGitInfoExcludePath } from "../git-utils";
8
+ import type { InstallRequirement } from "../install-contract";
7
9
  import type { CapabilityPlugin, RuntimeContributionsByRuntime } from "../plugin";
8
10
  import { isCommandOnPath, resolveCommandOnPath, runCommand, runShellCommand } from "../utils";
9
11
  import { TOKENSAVE_CLAUDE_MD_MARKER } from "./tokensave-shared";
@@ -73,6 +75,9 @@ export const tokensaveDeps = {
73
75
  pathValue(): string {
74
76
  return process.env.PATH ?? "";
75
77
  },
78
+ homeDir(): string {
79
+ return os.homedir();
80
+ },
76
81
  };
77
82
 
78
83
  async function cleanupRepoLocalTokensaveArtifacts(repoPath: string): Promise<void> {
@@ -109,11 +114,12 @@ async function tokensaveInstalled(repoPath: string): Promise<boolean> {
109
114
 
110
115
  /**
111
116
  * Upgrades the installed binary without making setup depend on network
112
- * availability. `--kill` keeps the non-interactive setup path from hanging on
113
- * a running Tokensave MCP process.
117
+ * availability. Never passes `--kill`: it stops every tokensave process on the
118
+ * machine, including MCP servers of running agent sessions. Without a TTY,
119
+ * tokensave upgrades and leaves them running.
114
120
  */
115
121
  async function upgradeTokensave(repoPath: string): Promise<void> {
116
- const result = tokensaveDeps.run(["upgrade", "--kill"], repoPath);
122
+ const result = tokensaveDeps.run(["upgrade"], repoPath);
117
123
  if (result.ok) return;
118
124
 
119
125
  const detail = result.stderr.trim();
@@ -122,17 +128,22 @@ async function upgradeTokensave(repoPath: string): Promise<void> {
122
128
  );
123
129
  }
124
130
 
125
- // Global agent integration (MCP entry, session hooks, wildcard permission grant, and
126
- // tokensave's own config bookkeeping) is owned by tokensave's installer — Mate never
127
- // hand-edits ~/.tokensave/config.toml. Runs in setup mode only; the installer is
128
- // idempotent, so re-running on every setup doubles as repair for stale global state.
131
+ /**
132
+ * Global agent integration (MCP entry, session hooks, wildcard permission grant, and
133
+ * tokensave's own config bookkeeping) is owned by tokensave's installer — Mate never
134
+ * hand-edits ~/.tokensave/config.toml. Idempotent, so re-running doubles as repair.
135
+ */
136
+ function installTokensaveAgentIntegration(agent: string, cwd: string): TokensaveRunResult {
137
+ return tokensaveDeps.run(
138
+ ["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
139
+ cwd,
140
+ );
141
+ }
142
+
129
143
  function installTokensaveAgentIntegrations(providers: string[], cwd: string): void {
130
144
  const agents = providers.filter((p) => TOKENSAVE_SUPPORTED_AGENTS.has(p)).sort();
131
145
  for (const agent of agents) {
132
- const result = tokensaveDeps.run(
133
- ["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
134
- cwd,
135
- );
146
+ const result = installTokensaveAgentIntegration(agent, cwd);
136
147
  if (!result.ok) {
137
148
  const detail = result.stderr.trim();
138
149
  process.stderr.write(
@@ -142,6 +153,60 @@ function installTokensaveAgentIntegrations(providers: string[], cwd: string): vo
142
153
  }
143
154
  }
144
155
 
156
+ async function readJsonObject(filePath: string): Promise<Record<string, unknown> | undefined> {
157
+ try {
158
+ const parsed: unknown = JSON.parse(await fs.readFile(filePath, "utf8"));
159
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
160
+ ? (parsed as Record<string, unknown>)
161
+ : undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ async function fileExists(filePath: string): Promise<boolean> {
168
+ try {
169
+ await fs.access(filePath, fsConstants.F_OK);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Read-only probe of the global files `tokensave install --agent <agent>` writes.
178
+ * tokensave's own permission check reads only these global files, never the
179
+ * companion-scoped settings Mate loads via `--settings`.
180
+ */
181
+ export async function isTokensaveAgentIntegrated(agent: string): Promise<boolean> {
182
+ const home = tokensaveDeps.homeDir();
183
+ if (agent === "claude") {
184
+ const settings = await readJsonObject(path.join(home, ".claude", "settings.json"));
185
+ const allow = (settings?.permissions as { allow?: unknown } | undefined)?.allow;
186
+ return (
187
+ Array.isArray(allow) &&
188
+ allow.includes("mcp__tokensave__*") &&
189
+ (await fileExists(path.join(home, ".claude", "rules", "tokensave.md")))
190
+ );
191
+ }
192
+ if (agent === "opencode") {
193
+ const configDir = path.join(home, ".config", "opencode");
194
+ const config = await readJsonObject(path.join(configDir, "opencode.json"));
195
+ /**
196
+ * `tokensave install` writes the flat `mcp.<name>` entry, which OpenCode 2.x
197
+ * still loads; `mcp.servers.<name>` is the 2.x-native spelling.
198
+ */
199
+ const mcp = config?.mcp as
200
+ | (Record<string, unknown> & { servers?: Record<string, unknown> })
201
+ | undefined;
202
+ return (
203
+ (mcp?.tokensave !== undefined || mcp?.servers?.tokensave !== undefined) &&
204
+ (await fileExists(path.join(configDir, "tokensave.md")))
205
+ );
206
+ }
207
+ return true;
208
+ }
209
+
145
210
  function getCargoRustVersionError(): string | undefined {
146
211
  const result = tokensaveDeps.rustcVersion();
147
212
  const version = `${result.stdout}\n${result.stderr}`.match(/\brustc\s+(\d+)\.(\d+)\.(\d+)/);
@@ -361,7 +426,7 @@ export function createTokensavePlugin(): CapabilityPlugin {
361
426
  "Enable TokenSave indexing in the repo and MCP access via companion-managed config.",
362
427
  defaultSelected: false,
363
428
  isEnabled: (config) => (config.capabilities ?? []).some((c) => c.name === "tokensave"),
364
- getInstallRequirements: () => {
429
+ getInstallRequirements: ({ config }) => {
365
430
  const plan = getTokensaveInstallPlan();
366
431
  return [
367
432
  {
@@ -382,6 +447,27 @@ export function createTokensavePlugin(): CapabilityPlugin {
382
447
  },
383
448
  verify: () => tokensaveDeps.run(["--version"], process.cwd()).ok,
384
449
  },
450
+ ...(config.allowedAgents ?? [])
451
+ .filter((agent) => TOKENSAVE_SUPPORTED_AGENTS.has(agent))
452
+ .sort()
453
+ .map((agent): InstallRequirement => ({
454
+ id: `capability:tokensave:${agent}`,
455
+ label: `TokenSave ${agent} integration`,
456
+ group: "companion",
457
+ source: "TokenSave capability",
458
+ command: `tokensave install --agent ${agent} --git-hook no --wildcard-permissions`,
459
+ fingerprint: `tokensave-agent:${agent}`,
460
+ detect: () => isTokensaveAgentIntegrated(agent),
461
+ install: async () => {
462
+ const result = installTokensaveAgentIntegration(agent, process.cwd());
463
+ if (!result.ok) {
464
+ const detail = result.stderr.trim();
465
+ throw new Error(
466
+ `\`tokensave install --agent ${agent}\` failed${detail ? `: ${detail}` : ""}`,
467
+ );
468
+ }
469
+ },
470
+ })),
385
471
  ];
386
472
  },
387
473
  // MCP access, session hooks, and permission pre-seeds are declared below
@@ -31,10 +31,7 @@ interface ContextServicesManifest {
31
31
  plugins: Record<string, PluginServiceRecord>;
32
32
  }
33
33
 
34
- export function getContextServicesManifestPath(
35
- companionPath: string,
36
- frameworkName: string,
37
- ): string {
34
+ function getContextServicesManifestPath(companionPath: string, frameworkName: string): string {
38
35
  return path.join(companionPath, `.${frameworkName}`, "state", "context-services.json");
39
36
  }
40
37
 
@@ -129,7 +126,7 @@ export async function removeManagedBlocksForPlugin(
129
126
  }
130
127
 
131
128
  /** Remove a marker-delimited block if present. No-op when file or block is absent. */
132
- export async function removeManagedBlock(
129
+ async function removeManagedBlock(
133
130
  filePath: string,
134
131
  frameworkName: string,
135
132
  blockKey: string,
@@ -164,7 +161,7 @@ export async function removeManagedBlock(
164
161
  * `playbooks/**`, and `wrappers/bin/*` below the root. Core's own templates
165
162
  * live at `src/templates`.
166
163
  */
167
- export async function resolveTemplateAsset(
164
+ async function resolveTemplateAsset(
168
165
  templatePath: string,
169
166
  overrideRoots: string[] = [],
170
167
  ): Promise<string> {
@@ -0,0 +1,47 @@
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 { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
8
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
9
+
10
+ function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+
14
+ /**
15
+ * Raw read of the companion's `plugins:` list. Deliberately avoids
16
+ * `ConfigStore.load()` — hydration runs on every invocation and must never
17
+ * create or migrate config files as a side effect.
18
+ */
19
+ export async function readDeclarations(companionPath: string): Promise<unknown[]> {
20
+ try {
21
+ const raw = await fs.readFile(
22
+ path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
23
+ "utf8",
24
+ );
25
+ const parsed = parse(raw) as unknown;
26
+ if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
27
+ return parsed.plugins;
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ export function validateDeclaration(entry: unknown): {
34
+ declaration?: PluginDeclaration;
35
+ error?: string;
36
+ } {
37
+ if (!isRecord(entry) || typeof entry.package !== "string" || typeof entry.version !== "string") {
38
+ return { error: `ignoring malformed plugins entry: ${JSON.stringify(entry)}` };
39
+ }
40
+ const policy = entry.policy;
41
+ if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
42
+ return {
43
+ error: `plugin "${entry.package}": policy "${String(policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
44
+ };
45
+ }
46
+ return { declaration: entry as unknown as PluginDeclaration };
47
+ }
@@ -0,0 +1,215 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
+ import type { NpmInstallRunner, PluginInstallResult } from "./install";
7
+ import { dynamicPluginsWorkspaceRoot } from "./paths";
8
+ import {
9
+ disallowedPluginMessage,
10
+ isPluginAllowed,
11
+ PluginPolicyError,
12
+ readPluginPolicy,
13
+ } from "./policy";
14
+
15
+ /** Raised before anything is installed: policy, manifest or lockfile inputs are unusable. */
16
+ export class FrozenInstallError extends Error {}
17
+
18
+ export interface FrozenInstallDeps {
19
+ /** Restores the exact locked tree; defaults to `npm ci`. */
20
+ runNpmCi?: NpmInstallRunner;
21
+ env?: Record<string, string | undefined>;
22
+ }
23
+
24
+ interface LockEntry {
25
+ version?: string;
26
+ integrity?: string;
27
+ optional?: boolean;
28
+ inBundle?: boolean;
29
+ link?: boolean;
30
+ }
31
+
32
+ interface Lockfile {
33
+ packages?: Record<string, LockEntry & { dependencies?: Record<string, string> }>;
34
+ }
35
+
36
+ function defaultNpmCi(workspaceRoot: string): { ok: boolean; detail?: string } {
37
+ const result = spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], {
38
+ cwd: workspaceRoot,
39
+ encoding: "utf8",
40
+ });
41
+ if (result.error || result.status !== 0) {
42
+ return {
43
+ ok: false,
44
+ detail:
45
+ result.error?.message ?? (result.stderr?.trim() || `npm ci exited with ${result.status}`),
46
+ };
47
+ }
48
+ return { ok: true };
49
+ }
50
+
51
+ async function readText(file: string): Promise<string | null> {
52
+ try {
53
+ return await fs.readFile(file, "utf8");
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function parseJson<T>(text: string, label: string): T {
60
+ try {
61
+ return JSON.parse(text) as T;
62
+ } catch {
63
+ throw new FrozenInstallError(`${label} is not valid JSON`);
64
+ }
65
+ }
66
+
67
+ function sameMap(a: Record<string, string>, b: Record<string, string>): string[] {
68
+ const differences: string[] = [];
69
+ for (const name of new Set([...Object.keys(a), ...Object.keys(b)])) {
70
+ if (a[name] !== b[name]) {
71
+ differences.push(
72
+ `${name} (declared ${a[name] ?? "absent"}, recorded ${b[name] ?? "absent"})`,
73
+ );
74
+ }
75
+ }
76
+ return differences.toSorted();
77
+ }
78
+
79
+ /**
80
+ * Compares the installed tree's hidden lockfile with the committed lockfile:
81
+ * every non-optional locked package must be installed at the locked version
82
+ * and integrity, and nothing extra may be installed. Package presence alone
83
+ * proves nothing.
84
+ */
85
+ async function treeMismatches(workspaceRoot: string, lock: Lockfile): Promise<string[]> {
86
+ const hiddenText = await readText(path.join(workspaceRoot, "node_modules", ".package-lock.json"));
87
+ if (hiddenText === null) return ["installed tree has no install record"];
88
+ let hidden: Lockfile;
89
+ try {
90
+ hidden = JSON.parse(hiddenText) as Lockfile;
91
+ } catch {
92
+ return ["installed tree's install record is unreadable"];
93
+ }
94
+ const installed = hidden.packages ?? {};
95
+ const mismatches: string[] = [];
96
+ for (const [key, locked] of Object.entries(lock.packages ?? {})) {
97
+ if (key === "" || locked.link) continue;
98
+ const present = installed[key];
99
+ if (!present) {
100
+ if (!locked.optional) mismatches.push(`${key} is not installed`);
101
+ continue;
102
+ }
103
+ if (present.version !== locked.version || present.integrity !== locked.integrity) {
104
+ mismatches.push(`${key} differs from the lockfile`);
105
+ }
106
+ }
107
+ for (const key of Object.keys(installed)) {
108
+ if (!lock.packages?.[key]) mismatches.push(`${key} is installed but not locked`);
109
+ }
110
+ return mismatches;
111
+ }
112
+
113
+ /**
114
+ * Deployment restore: installs exactly what the committed lockfile records.
115
+ * Inputs are validated before npm runs — the appliance allowlist, the
116
+ * workspace manifest against the declarations, and the lockfile against the
117
+ * manifest — and neither tracked file is ever rewritten. An installed tree is
118
+ * reused only when it matches the lockfile's versions and integrity.
119
+ */
120
+ export async function installDeclaredPluginsFrozen(
121
+ companionPath: string,
122
+ declarations: PluginDeclaration[],
123
+ deps: FrozenInstallDeps = {},
124
+ ): Promise<PluginInstallResult[]> {
125
+ const workspaceRoot = dynamicPluginsWorkspaceRoot(companionPath);
126
+ const sorted = declarations.toSorted((a, b) => a.package.localeCompare(b.package));
127
+ const desired: Record<string, string> = {};
128
+ for (const declaration of sorted) desired[declaration.package] = declaration.version;
129
+
130
+ let policy: ReturnType<typeof readPluginPolicy>;
131
+ try {
132
+ policy = readPluginPolicy(deps.env ?? process.env);
133
+ } catch (error) {
134
+ if (error instanceof PluginPolicyError) throw new FrozenInstallError(error.message);
135
+ throw error;
136
+ }
137
+ const refused = sorted.filter((declaration) => !isPluginAllowed(policy, declaration.package));
138
+ if (refused.length > 0) {
139
+ throw new FrozenInstallError(
140
+ refused.map((declaration) => disallowedPluginMessage(declaration.package)).join("\n"),
141
+ );
142
+ }
143
+
144
+ const manifestFile = path.join(workspaceRoot, "package.json");
145
+ const lockFile = path.join(workspaceRoot, "package-lock.json");
146
+ const [manifestText, lockText] = await Promise.all([readText(manifestFile), readText(lockFile)]);
147
+ if (manifestText === null) {
148
+ throw new FrozenInstallError(
149
+ `${manifestFile} is missing; commit the plugin workspace manifest in the companion's authoring flow`,
150
+ );
151
+ }
152
+ if (lockText === null) {
153
+ throw new FrozenInstallError(
154
+ `${lockFile} is missing; commit the plugin lockfile in the companion's authoring flow`,
155
+ );
156
+ }
157
+
158
+ const manifest = parseJson<{ dependencies?: Record<string, string> }>(manifestText, manifestFile);
159
+ const manifestDrift = sameMap(desired, manifest.dependencies ?? {});
160
+ if (manifestDrift.length > 0) {
161
+ throw new FrozenInstallError(
162
+ `plugin workspace manifest disagrees with framework.yaml: ${manifestDrift.join("; ")}`,
163
+ );
164
+ }
165
+ const lock = parseJson<Lockfile>(lockText, lockFile);
166
+ const lockRoot = lock.packages?.[""]?.dependencies ?? {};
167
+ const lockDrift = sameMap(desired, lockRoot);
168
+ if (lockDrift.length > 0) {
169
+ throw new FrozenInstallError(
170
+ `plugin lockfile disagrees with framework.yaml: ${lockDrift.join("; ")}`,
171
+ );
172
+ }
173
+ const unlocked = sorted.filter((declaration) => {
174
+ const entry = lock.packages?.[`node_modules/${declaration.package}`];
175
+ return !entry?.version || !entry.integrity;
176
+ });
177
+ if (unlocked.length > 0) {
178
+ throw new FrozenInstallError(
179
+ `plugin lockfile has no resolved version and integrity for: ${unlocked.map((d) => d.package).join(", ")}`,
180
+ );
181
+ }
182
+
183
+ const lockedVersion = (name: string) => lock.packages?.[`node_modules/${name}`]?.version;
184
+ const fail = (error: string): PluginInstallResult[] =>
185
+ sorted.map((declaration) => ({ package: declaration.package, status: "failed", error }));
186
+
187
+ if ((await treeMismatches(workspaceRoot, lock)).length === 0) {
188
+ return sorted.map((declaration) => ({
189
+ package: declaration.package,
190
+ status: "unchanged",
191
+ resolvedVersion: lockedVersion(declaration.package),
192
+ }));
193
+ }
194
+
195
+ const outcome = await (deps.runNpmCi ?? defaultNpmCi)(workspaceRoot);
196
+ if (!outcome.ok) return fail(outcome.detail ?? "npm ci failed");
197
+
198
+ const [manifestAfter, lockAfter] = await Promise.all([
199
+ readText(manifestFile),
200
+ readText(lockFile),
201
+ ]);
202
+ if (manifestAfter !== manifestText || lockAfter !== lockText) {
203
+ await Promise.all([fs.writeFile(manifestFile, manifestText), fs.writeFile(lockFile, lockText)]);
204
+ return fail("the restore changed the committed manifest or lockfile; they were put back");
205
+ }
206
+ const remaining = await treeMismatches(workspaceRoot, lock);
207
+ if (remaining.length > 0) {
208
+ return fail(`installed tree does not match the lockfile: ${remaining.join("; ")}`);
209
+ }
210
+ return sorted.map((declaration) => ({
211
+ package: declaration.package,
212
+ status: "installed",
213
+ resolvedVersion: lockedVersion(declaration.package),
214
+ }));
215
+ }