@uniqbit/mate-core 0.18.3-canary.0 → 0.18.3

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 (96) hide show
  1. package/claude-plugin/hooks/hooks.json +10 -1
  2. package/claude-plugin/hooks/session-banner.mjs +4 -3
  3. package/claude-plugin/hooks/session-prepare.mjs +9 -0
  4. package/opencode-plugin/src/companion.ts +47 -12
  5. package/opencode-plugin/src/server.ts +39 -4
  6. package/package.json +3 -3
  7. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  8. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  9. package/src/cli/commands/companion/link.ts +16 -11
  10. package/src/cli/commands/companion/list.ts +4 -5
  11. package/src/cli/commands/companion/open.ts +4 -5
  12. package/src/cli/commands/companion/tui.ts +1 -1
  13. package/src/cli/commands/doctor.ts +1 -1
  14. package/src/cli/commands/report/renderer.ts +9 -19
  15. package/src/cli/commands/session/prepare.ts +119 -0
  16. package/src/cli/commands/shared/companion-selection.ts +2 -2
  17. package/src/cli/commands/studio/reports.ts +0 -1
  18. package/src/cli/commands/studio/server.ts +6 -2
  19. package/src/cli/commands/unwrap.ts +28 -3
  20. package/src/cli/commands/wrap.ts +53 -59
  21. package/src/cli/companion-selector.tsx +1 -1
  22. package/src/cli/main.ts +17 -0
  23. package/src/cli/usage.ts +3 -1
  24. package/src/hooks/session-banner.ts +19 -10
  25. package/src/hooks/session-prepare.ts +101 -0
  26. package/src/lib/fs-utils.ts +13 -0
  27. package/src/lib/orchestrator/adapters/base.ts +43 -8
  28. package/src/lib/orchestrator/adapters/claude.ts +130 -29
  29. package/src/lib/orchestrator/adapters/opencode.ts +17 -19
  30. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  31. package/src/lib/orchestrator/companion-hub.ts +2 -1
  32. package/src/lib/orchestrator/config-store.ts +56 -2
  33. package/src/lib/orchestrator/framework-context.ts +5 -29
  34. package/src/lib/orchestrator/launcher.ts +36 -171
  35. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  36. package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
  37. package/src/lib/orchestrator/projection-entries.ts +78 -11
  38. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  39. package/src/lib/orchestrator/projection-record.ts +7 -1
  40. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  41. package/src/lib/orchestrator/projection-types.ts +38 -33
  42. package/src/lib/orchestrator/repository-link.ts +473 -0
  43. package/src/lib/orchestrator/root-context.ts +6 -2
  44. package/src/lib/orchestrator/session-prepare.ts +425 -0
  45. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  46. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  47. package/src/lib/orchestrator/types.ts +4 -1
  48. package/src/lib/orchestrator/working-repo-projection.ts +71 -159
  49. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  50. package/src/lib/orchestrator/wrap-pass.ts +131 -0
  51. package/src/lib/runtime-directory-refresh.ts +186 -0
  52. package/src/lib/update-checker.ts +6 -0
  53. package/src/opencode/companion-hooks.ts +24 -132
  54. package/src/opencode/companion-policy.ts +26 -10
  55. package/src/opencode/index.ts +1 -7
  56. package/src/opencode/session-notes.ts +76 -0
  57. package/src/opencode/session-prepare.ts +139 -0
  58. package/src/opencode/tui.tsx +56 -9
  59. package/src/runtime/claude-delivery-plan.ts +73 -0
  60. package/src/runtime/claude-hook-group.ts +21 -0
  61. package/src/runtime/companion-lock.ts +121 -0
  62. package/src/runtime/companion-sync.ts +66 -9
  63. package/src/runtime/runtime-directory.ts +43 -0
  64. package/src/runtime/runtime-document-manifest.ts +335 -0
  65. package/src/runtime/session-prepare-runner.ts +148 -0
  66. package/src/runtime/wrap-state.ts +90 -0
  67. package/src/runtime/write-atomic.ts +27 -0
  68. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  69. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  70. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  71. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  72. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  73. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  74. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  75. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  76. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  77. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  78. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  79. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
  80. package/src/tools/setup/engine.ts +15 -4
  81. package/src/tools/setup/mate.ts +1 -1
  82. package/src/tools/setup/providers/claude-format.ts +11 -18
  83. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  84. package/src/tools/setup/providers/claude.ts +23 -6
  85. package/src/tools/setup/runtime-documents.ts +152 -32
  86. package/src/tools/setup/working-repo-local-state.ts +82 -13
  87. package/src/tools/setup.ts +2 -2
  88. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  89. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  90. package/src/lib/orchestrator/companion-store.ts +0 -94
  91. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  92. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  93. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  94. package/src/templates/report-assets/README.md +0 -32
  95. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  96. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -1,8 +1,6 @@
1
- import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
1
  import path from "node:path";
4
2
 
5
- import type { Cleanup, Context } from "@opencode/plugin/promise/plugin";
3
+ import type { Context } from "@opencode/plugin/promise/plugin";
6
4
 
7
5
  import {
8
6
  companionForkRefusal,
@@ -11,6 +9,8 @@ import {
11
9
  } from "../runtime/companion-sync";
12
10
  import { hasLaunchEnvironment } from "../runtime/env";
13
11
  import { MATE_ENV } from "../runtime/env-names";
12
+ import { resolveProjection } from "../runtime/projection";
13
+ import { readWrapStateSync } from "../runtime/wrap-state";
14
14
  import {
15
15
  buildArtifactError,
16
16
  extractPatchPaths,
@@ -20,103 +20,13 @@ import {
20
20
  type CompanionContext,
21
21
  } from "./companion-policy";
22
22
 
23
- const REACT_DOCTOR_VERSION = "0.9.13";
24
-
25
- const REACT_DOCTOR_NON_LINT_FAILURES = [
26
- /No React dependency found/i,
27
- /Could not resolve config/i,
28
- /is not a git repository/i,
29
- ];
30
-
31
- const REACT_DOCTOR_EDIT_TOOLS = new Set(["write", "edit", "apply_patch"]);
32
- const REACT_DOCTOR_ARGS = [
33
- "--yes",
34
- "--verbose",
35
- "--scope",
36
- "changed",
37
- "--base",
38
- "HEAD",
39
- "--blocking",
40
- "warning",
41
- "--no-score",
42
- "--max-duration",
43
- "30",
44
- ];
45
-
46
- export type CommandResult = { exitCode: number | null; output: string };
47
- export type CommandRunner = (
48
- command: string,
49
- args: string[],
50
- cwd: string,
51
- ) => Promise<CommandResult>;
52
- type SessionPrompt = Pick<Context["session"], "prompt">;
53
-
54
- /** Combined stdout and stderr; rejects only when the process cannot be spawned. */
55
- export const runCommand: CommandRunner = (command, args, cwd) =>
56
- new Promise((resolve, reject) => {
57
- const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
58
- let output = "";
59
- child.stdout.setEncoding("utf8").on("data", (chunk: string) => (output += chunk));
60
- child.stderr.setEncoding("utf8").on("data", (chunk: string) => (output += chunk));
61
- child.once("error", reject);
62
- child.once("close", (exitCode) => resolve({ exitCode, output: output.trim() }));
63
- });
64
-
65
- function reactDoctorCommand(repo: string): { command: string; args: string[] } {
66
- const localBin = path.join(repo, "node_modules", ".bin", "react-doctor");
67
- const mateBin = process.env.MATE_REACT_DOCTOR_BIN_PATH;
68
- const doctorBin = mateBin && fs.existsSync(mateBin) ? mateBin : localBin;
69
- return fs.existsSync(doctorBin)
70
- ? { command: doctorBin, args: REACT_DOCTOR_ARGS }
71
- : {
72
- command: "npx",
73
- args: ["--yes", `react-doctor@${REACT_DOCTOR_VERSION}`, ...REACT_DOCTOR_ARGS],
74
- };
75
- }
76
-
77
- /**
78
- * Tracks edited sessions and scans each at most once per idle, never twice
79
- * concurrently for the same session.
80
- */
81
- export function createReactDoctorScanner(
82
- context: CompanionContext,
83
- session: SessionPrompt,
84
- run: CommandRunner = runCommand,
85
- ) {
86
- const dirtySessions = new Set<string>();
87
- const scansInFlight = new Set<string>();
88
-
89
- return {
90
- markEdited(tool: string, sessionID: string): void {
91
- if (REACT_DOCTOR_EDIT_TOOLS.has(tool)) dirtySessions.add(sessionID);
92
- },
93
- async idle(sessionID: string): Promise<void> {
94
- const repo = context.repositoryPath;
95
- if (!repo || scansInFlight.has(sessionID) || !dirtySessions.delete(sessionID)) return;
96
- scansInFlight.add(sessionID);
97
- try {
98
- const { command, args } = reactDoctorCommand(repo);
99
- const { exitCode, output } = await run(command, args, repo);
100
- if (exitCode === 0 || !output) return;
101
- if (REACT_DOCTOR_NON_LINT_FAILURES.some((re) => re.test(output))) return;
102
-
103
- await session
104
- .prompt({
105
- sessionID,
106
- text:
107
- "React Doctor found issues in the changed files. Review this output and fix " +
108
- "the regressions before finishing. For confirmed issues that cannot be fixed " +
109
- "now, create GitHub issues with the rule, file/line, confidence, impact, and " +
110
- `proposed fix.\n\n${output}`,
111
- } as never)
112
- .catch(() => {});
113
- } catch {
114
- /** Never break the session because a diagnostic scan failed. */
115
- } finally {
116
- scansInFlight.delete(sessionID);
117
- }
118
- },
119
- };
23
+ /** The Working Repository above `directory`, when it is wrapped for OpenCode. */
24
+ export function repositoryWrappedForOpenCode(directory: string): string | undefined {
25
+ const projection = resolveProjection(directory);
26
+ if (!projection || !readWrapStateSync(projection.repoRoot).runtimes.has("opencode")) {
27
+ return undefined;
28
+ }
29
+ return projection.repoRoot;
120
30
  }
121
31
 
122
32
  /**
@@ -126,14 +36,17 @@ export function createReactDoctorScanner(
126
36
  const repairedCompanions = new Set<string>();
127
37
 
128
38
  /**
129
- * Session start is the only point at which the companion may move without
130
- * invalidating a read the session has already taken. The launch-environment
131
- * gate comes first so a Managed Session does no Git work at all; the consent
132
- * gate is the operation's own.
39
+ * A fetch at session start, for a location Session Prepare does not serve.
40
+ * In a repository wrapped for OpenCode the plugin's background sync performs
41
+ * the same unattended operation, so this one steps aside. Elsewhere it only
42
+ * tries the companion lock: a held lock means another sync is already on it.
43
+ * The launch-environment gate comes first so a Managed Session does no Git
44
+ * work at all; the consent gate is the operation's own.
133
45
  */
134
46
  export async function repairCompanionGitOnce(
135
47
  context: CompanionContext,
136
48
  env: Record<string, string | undefined> = process.env,
49
+ directory?: string,
137
50
  ): Promise<string[]> {
138
51
  if (
139
52
  !context.companionPath ||
@@ -141,6 +54,7 @@ export async function repairCompanionGitOnce(
141
54
  (env[MATE_ENV.repositoryPath] !== undefined || env[MATE_ENV.gitAutoMode] !== "1"))
142
55
  )
143
56
  return [];
57
+ if (directory && repositoryWrappedForOpenCode(directory)) return [];
144
58
  if (repairedCompanions.has(context.companionPath)) return [];
145
59
  repairedCompanions.add(context.companionPath);
146
60
 
@@ -149,7 +63,10 @@ export async function repairCompanionGitOnce(
149
63
  * unattended. The V2 server API has no toast; the notes reach the operator
150
64
  * through the persisted record and the TUI's staleness lines.
151
65
  */
152
- return unattendedSyncStalenessLines(await syncCompanionUnattended(context.companionPath));
66
+ return unattendedSyncStalenessLines(
67
+ await syncCompanionUnattended(context.companionPath, { lock: "try" }),
68
+ "opencode",
69
+ );
153
70
  }
154
71
 
155
72
  /** Test seam: the once-per-session guard is process-wide by design. */
@@ -200,11 +117,6 @@ export async function guardToolInput(
200
117
  }
201
118
  }
202
119
 
203
- export type CompanionHooksOptions = {
204
- /** Test seam for the React Doctor process. */
205
- run?: CommandRunner;
206
- };
207
-
208
120
  /**
209
121
  * Registers the companion guardrails on an OpenCode V2 plugin context.
210
122
  * The caller resolves `context` and skips this when no companion resolved.
@@ -212,34 +124,14 @@ export type CompanionHooksOptions = {
212
124
  export async function registerCompanionHooks(
213
125
  api: Context,
214
126
  context: CompanionContext,
215
- options: CompanionHooksOptions = {},
216
- ): Promise<Cleanup | undefined> {
127
+ ): Promise<void> {
217
128
  /**
218
129
  * Anything escaping the repair would cost the session every guardrail
219
130
  * below to save a synchronization that is optional by design.
220
131
  */
221
- await repairCompanionGitOnce(context).catch(() => []);
132
+ await repairCompanionGitOnce(context, process.env, api.location.directory).catch(() => []);
222
133
 
223
134
  await api.tool.hook("execute.before", (event) =>
224
135
  guardToolInput(context, event.tool, event.input),
225
136
  );
226
-
227
- if (!context.repositoryPath || !context.reactDoctorEnabled) return;
228
-
229
- const scanner = createReactDoctorScanner(context, api.session, options.run);
230
- await api.tool.hook("execute.after", (event) => {
231
- if (event.status === "completed") scanner.markEdited(event.tool, event.sessionID);
232
- });
233
-
234
- const controller = new AbortController();
235
- void (async () => {
236
- try {
237
- for await (const event of api.event.subscribe({ signal: controller.signal })) {
238
- if (event.type === "session.idle") await scanner.idle(event.data.sessionID);
239
- }
240
- } catch {
241
- /** Plugin shutdown aborts the subscription; event delivery is best effort. */
242
- }
243
- })();
244
- return () => controller.abort();
245
137
  }
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { persistedCompanionGitStalenessLines } from "../runtime/companion-sync";
6
6
  import { resolveCompanionRuntime, type CompanionRuntimeContext } from "../runtime/env";
7
7
  import { projectionFreshness, projectionStalenessLines } from "../runtime/freshness";
8
+ import { readSessionNotes } from "./session-notes";
8
9
 
9
10
  export type CompanionContext = CompanionRuntimeContext & {
10
11
  agentsMd: string;
@@ -189,14 +190,29 @@ export function readContext(
189
190
  }
190
191
  }
191
192
 
192
- return {
193
- ...context,
194
- agentsMd,
195
- stalenessLines: projection
196
- ? [
197
- ...projectionStalenessLines(projection, projectionFreshness(projection)),
198
- ...persistedCompanionGitStalenessLines(context.companionPath),
199
- ]
200
- : [],
201
- };
193
+ return { ...context, agentsMd, stalenessLines: stalenessLinesFor(context, projection) };
194
+ }
195
+
196
+ function stalenessLinesFor(
197
+ context: CompanionRuntimeContext,
198
+ projection: ReturnType<typeof resolveCompanionRuntime>["projection"],
199
+ ): string[] {
200
+ if (!projection) return [];
201
+ /** A sync note repeats the persisted Git line it came from. */
202
+ return [
203
+ ...new Set([
204
+ ...projectionStalenessLines(projection, projectionFreshness(projection)),
205
+ ...persistedCompanionGitStalenessLines(context.companionPath, undefined, "opencode"),
206
+ ...(context.repositoryPath ? readSessionNotes(context.repositoryPath) : []),
207
+ ]),
208
+ ];
209
+ }
210
+
211
+ /** The session state alone, cheap enough for the TUI to poll: no `AGENTS.md` read, no Git. */
212
+ export function readStalenessLines(
213
+ env: Record<string, string | undefined> = process.env,
214
+ cwd: string = process.cwd(),
215
+ ): string[] {
216
+ const { context, projection } = resolveCompanionRuntime(env, cwd);
217
+ return stalenessLinesFor(context, projection);
202
218
  }
@@ -5,12 +5,6 @@
5
5
  * test). Must not reach OpenTUI or Solid: the Mate TUI plugin lives on the
6
6
  * `./opencode/tui` subpath.
7
7
  */
8
- export {
9
- createReactDoctorScanner,
10
- guardToolInput,
11
- registerCompanionHooks,
12
- type CommandRunner,
13
- type CompanionHooksOptions,
14
- } from "./companion-hooks";
8
+ export { guardToolInput, registerCompanionHooks } from "./companion-hooks";
15
9
  export { resolveOpenCodeGuidance, type OpenCodeGuidanceResolution } from "./projected-guidance";
16
10
  export * from "./companion-policy";
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { repoLocalDirPath } from "../runtime/repo-local";
6
+ import { writeAtomic } from "../runtime/write-atomic";
7
+
8
+ /**
9
+ * The last plugin prepare's operator notes, per phase. The V2 server API has
10
+ * no toast, so the TUI reads them with its staleness lines and toasts new ones.
11
+ */
12
+ export type SessionNotePhase = "prepare" | "sync";
13
+
14
+ type SessionNotes = Partial<Record<SessionNotePhase, string[]>>;
15
+
16
+ function notesPath(repoPath: string): string {
17
+ return path.join(repoLocalDirPath(repoPath), "session-notes.json");
18
+ }
19
+
20
+ function readAll(repoPath: string): SessionNotes {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(notesPath(repoPath), "utf8")) as unknown;
23
+ return typeof parsed === "object" && parsed !== null ? (parsed as SessionNotes) : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Per notes file, the record still in flight. Each record reads the file and
31
+ * writes it back; the background sync's report can overlap the next prepare's,
32
+ * and an unordered write would bring back notes the other just cleared.
33
+ */
34
+ const inFlight = new Map<string, Promise<void>>();
35
+
36
+ /** Replaces one phase's notes; an empty list clears them. Best-effort. */
37
+ export function recordSessionNotes(
38
+ repoPath: string,
39
+ phase: SessionNotePhase,
40
+ notes: readonly string[],
41
+ ): Promise<void> {
42
+ const target = notesPath(repoPath);
43
+ const recorded = (inFlight.get(target) ?? Promise.resolve()).then(() =>
44
+ writeNotes(repoPath, target, phase, notes),
45
+ );
46
+ inFlight.set(target, recorded);
47
+ void recorded.then(() => {
48
+ if (inFlight.get(target) === recorded) inFlight.delete(target);
49
+ });
50
+ return recorded;
51
+ }
52
+
53
+ async function writeNotes(
54
+ repoPath: string,
55
+ target: string,
56
+ phase: SessionNotePhase,
57
+ notes: readonly string[],
58
+ ): Promise<void> {
59
+ const next: SessionNotes = { ...readAll(repoPath), [phase]: [...notes] };
60
+ try {
61
+ if (Object.values(next).every((lines) => !lines?.length)) {
62
+ await fsp.rm(target, { force: true });
63
+ return;
64
+ }
65
+ await fsp.mkdir(path.dirname(target), { recursive: true });
66
+ await writeAtomic(target, `${JSON.stringify(next, null, 2)}\n`);
67
+ } catch {
68
+ /* the log already carries the notes */
69
+ }
70
+ }
71
+
72
+ export function readSessionNotes(repoPath: string): string[] {
73
+ return Object.values(readAll(repoPath)).flatMap((lines) =>
74
+ Array.isArray(lines) ? lines.filter((line) => typeof line === "string") : [],
75
+ );
76
+ }
@@ -0,0 +1,139 @@
1
+ import type { Cleanup, Context } from "@opencode/plugin/promise/plugin";
2
+
3
+ import {
4
+ PREPARE_TIMEOUT_MS,
5
+ type PrepareRun,
6
+ type PrepareRunner,
7
+ } from "../runtime/session-prepare-runner";
8
+ import { recordSessionNotes, type SessionNotePhase } from "./session-notes";
9
+
10
+ /**
11
+ * Plugin side of `mate session prepare`: a bare OpenCode session in a wrapped
12
+ * repo gets a managed launch's local prepare, then a background Git sync.
13
+ * Spawned, so a crash or stale module never reaches the shared service.
14
+ */
15
+
16
+ export interface SessionPrepareOptions {
17
+ directory: string;
18
+ repoPath: string;
19
+ companionPath: string;
20
+ /** Mate-declared MCP servers; only these are held back until the local phase ends. */
21
+ mateServers: readonly string[];
22
+ run: PrepareRunner;
23
+ log?: (line: string) => void;
24
+ timeoutMs?: number;
25
+ debounceMs?: number;
26
+ }
27
+
28
+ const PREPARE_DEBOUNCE_MS = 5_000;
29
+
30
+ /** Keyed by companion: several repositories, and several locations, share one companion. */
31
+ const lastSynced = new Map<string, number>();
32
+
33
+ /** Test seam: the debounce is process-wide. */
34
+ export function resetPrepareDebounce(): void {
35
+ lastSynced.clear();
36
+ }
37
+
38
+ function logger(options: SessionPrepareOptions): (line: string) => void {
39
+ return options.log ?? ((line: string) => console.error(line));
40
+ }
41
+
42
+ /** Logs the notes and records them for the TUI's toast. */
43
+ async function report(
44
+ options: SessionPrepareOptions,
45
+ run: PrepareRun,
46
+ phase: SessionNotePhase,
47
+ ): Promise<void> {
48
+ const log = logger(options);
49
+ const verdict = run.ok ? "warning" : "failed";
50
+ for (const note of run.notes) log(`mate: session ${phase} ${verdict}: ${note}`);
51
+ await recordSessionNotes(options.repoPath, phase, run.notes);
52
+ }
53
+
54
+ function failedRun(error: unknown): PrepareRun {
55
+ return { ok: false, notes: [error instanceof Error ? error.message : String(error)] };
56
+ }
57
+
58
+ /** No remote: OpenCode holds MCP servers and skills until `setup` returns. */
59
+ async function prepareLocal(options: SessionPrepareOptions): Promise<void> {
60
+ const run = await options
61
+ .run({ sync: false, timeoutMs: options.timeoutMs ?? PREPARE_TIMEOUT_MS })
62
+ .catch(failedRun);
63
+ await report(options, run, "prepare");
64
+ }
65
+
66
+ /** Never awaited: the session runs on while the companion is fetched. */
67
+ function startSync(api: Context, options: SessionPrepareOptions): void {
68
+ lastSynced.set(options.companionPath, Date.now());
69
+ void options
70
+ .run({ sync: true })
71
+ .catch(failedRun)
72
+ .then(async (run) => {
73
+ await report(options, run, "sync");
74
+ if (!run.changed) return;
75
+ await api.mcp.reload().catch(() => {});
76
+ logger(options)("mate: companion updated; MCP servers reloaded.");
77
+ });
78
+ }
79
+
80
+ function eventDirectory(event: unknown): string | undefined {
81
+ const record = event as {
82
+ location?: { directory?: string };
83
+ data?: { location?: { directory?: string } };
84
+ };
85
+ return record.location?.directory ?? record.data?.location?.directory;
86
+ }
87
+
88
+ /**
89
+ * Holds the Mate MCP servers back, runs the local phase before `setup`
90
+ * returns, releases them, then syncs the companion in the background; syncs
91
+ * again on new sessions of this location. Neither a failed nor a slow prepare
92
+ * keeps the session from starting, and none is ever terminated.
93
+ */
94
+ export async function registerSessionPrepare(
95
+ api: Context,
96
+ options: SessionPrepareOptions,
97
+ ): Promise<Cleanup> {
98
+ const held = new Set(options.mateServers);
99
+ let gated = held.size > 0;
100
+ if (gated) {
101
+ await api.mcp.transform((editor) => {
102
+ if (!gated) return;
103
+ for (const name of held) {
104
+ if (editor.get(name)) {
105
+ editor.update(name, (config) => {
106
+ config.disabled = true;
107
+ });
108
+ }
109
+ }
110
+ });
111
+ }
112
+
113
+ try {
114
+ await prepareLocal(options);
115
+ } finally {
116
+ if (gated) {
117
+ gated = false;
118
+ await api.mcp.reload().catch(() => {});
119
+ }
120
+ }
121
+ startSync(api, options);
122
+
123
+ const controller = new AbortController();
124
+ const debounceMs = options.debounceMs ?? PREPARE_DEBOUNCE_MS;
125
+ void (async () => {
126
+ try {
127
+ for await (const event of api.event.subscribe({ signal: controller.signal })) {
128
+ if (event.type !== "session.created") continue;
129
+ if (eventDirectory(event) !== options.directory) continue;
130
+ const last = lastSynced.get(options.companionPath) ?? 0;
131
+ if (Date.now() - last < debounceMs) continue;
132
+ startSync(api, options);
133
+ }
134
+ } catch {
135
+ /* subscription closed: the location is shutting down */
136
+ }
137
+ })();
138
+ return () => controller.abort();
139
+ }
@@ -4,10 +4,10 @@
4
4
  * OpenCode V2 TUI plugin module (`{ id, setup }`), OpenCode 2.x only.
5
5
  * Solid JSX, not React: `key` is not a reconciliation prop here and `TextProps`
6
6
  * does not accept it, so the jsx-key rules cannot be satisfied — only suppressed.
7
- * Static arrays render through `.map`; a reactive one would need `<For>`.
7
+ * Arrays render through `.map`: Solid re-runs it whole on a store change, fine for a few lines.
8
8
  */
9
9
  import { mateVersion } from "../runtime/install";
10
- import { readContext } from "./companion-policy";
10
+ import { readContext, readStalenessLines } from "./companion-policy";
11
11
 
12
12
  /** Minimal structural slice of the OpenCode V2 TUI API; the published SDK ships no V2 TUI types. */
13
13
  export type MateTuiApi = {
@@ -20,6 +20,14 @@ export type MateTuiApi = {
20
20
  };
21
21
  ui: {
22
22
  slot(claim: MateSlotClaim): () => void;
23
+ toast: { show(options: { title?: string; message: string; variant?: "warning" }): void };
24
+ };
25
+ storage: {
26
+ /** A host Solid store: reads inside a render are reactive. */
27
+ memory<Value extends object>(
28
+ key: string,
29
+ options: { readonly initial: Value },
30
+ ): readonly [Value, (mutation: (draft: Value) => void) => void];
23
31
  };
24
32
  };
25
33
 
@@ -31,13 +39,26 @@ export type MateSlotClaim = {
31
39
 
32
40
  export type MateTuiPluginModule = {
33
41
  id: string;
34
- setup(api: MateTuiApi): void | Promise<void>;
42
+ setup(api: MateTuiApi): void | (() => void) | Promise<void | (() => void)>;
35
43
  };
36
44
 
45
+ /** A background sync finishes after the first render; the lines must follow it. */
46
+ export const STALENESS_POLL_MS = 5_000;
47
+
48
+ type SessionNotes = { lines: string[] };
49
+
37
50
  const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
38
51
  const MATE_VERSION = process.env.MATE_VERSION ?? mateVersion();
39
52
 
40
- function SessionContext({ api, sidebar = false }: { api: MateTuiApi; sidebar?: boolean }) {
53
+ function SessionContext({
54
+ api,
55
+ notes,
56
+ sidebar = false,
57
+ }: {
58
+ api: MateTuiApi;
59
+ notes: SessionNotes;
60
+ sidebar?: boolean;
61
+ }) {
41
62
  const context = readContext();
42
63
  const text = api.theme.text;
43
64
  const warning = text.feedback?.warning?.base ?? text.muted;
@@ -54,21 +75,47 @@ function SessionContext({ api, sidebar = false }: { api: MateTuiApi; sidebar?: b
54
75
  <text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
55
76
  <text fg={text.muted}>repo: {context.repositoryPath}</text>
56
77
  <text fg={text.muted}>mate: {context.companionPath}</text>
57
- {context.stalenessLines.map((note) => (
78
+ {notes.lines.map((note) => (
58
79
  <text fg={warning}>{note}</text>
59
80
  ))}
60
81
  </box>
61
82
  );
62
83
  }
63
84
 
64
- function setup(api: MateTuiApi): void {
85
+ /** Polls the session lines; a line that appears after startup is also raised as a toast. */
86
+ function setup(api: MateTuiApi): (() => void) | undefined {
65
87
  const context = readContext();
66
88
  if (!context.companionPath || !context.repositoryPath) {
67
- return;
89
+ return undefined;
68
90
  }
69
91
 
70
- api.ui.slot({ prepend: "home.footer", render: () => <SessionContext api={api} /> });
71
- api.ui.slot({ append: "sidebar.content", render: () => <SessionContext api={api} sidebar /> });
92
+ const [notes, setNotes] = api.storage.memory<SessionNotes>("mate-session-notes", {
93
+ initial: { lines: context.stalenessLines },
94
+ });
95
+ setNotes((draft) => {
96
+ draft.lines = context.stalenessLines;
97
+ });
98
+ const timer = setInterval(() => {
99
+ const lines = readStalenessLines();
100
+ if (lines.join("\n") === notes.lines.join("\n")) return;
101
+ const shown = new Set(notes.lines);
102
+ const added = lines.filter((line) => !shown.has(line));
103
+ setNotes((draft) => {
104
+ draft.lines = lines;
105
+ });
106
+ for (const message of added) api.ui.toast.show({ title: "mate", message, variant: "warning" });
107
+ }, STALENESS_POLL_MS);
108
+ timer.unref?.();
109
+
110
+ api.ui.slot({
111
+ prepend: "home.footer",
112
+ render: () => <SessionContext api={api} notes={notes} />,
113
+ });
114
+ api.ui.slot({
115
+ append: "sidebar.content",
116
+ render: () => <SessionContext api={api} notes={notes} sidebar />,
117
+ });
118
+ return () => clearInterval(timer);
72
119
  }
73
120
 
74
121
  const plugin: MateTuiPluginModule = {
@@ -0,0 +1,73 @@
1
+ import type { ClaudeHookGroup } from "./claude-hook-group";
2
+ import { NOT_WRAPPED, readWrapStateSync, type WrapState } from "./wrap-state";
3
+
4
+ /**
5
+ * Claude Delivery Plan: the owner of each Claude contribution, as one pure
6
+ * function. Callers act on it and decide no ownership. In `runtime/` so hooks
7
+ * may import it.
8
+ */
9
+
10
+ /**
11
+ * `managed-repo`: `mate claude` with a Working Repository; its prepare just
12
+ * reconciled the Claude wrap, which the launcher asserts. `managed-companion`:
13
+ * a companion-scoped launch. `unmanaged`: a session with no launch environment.
14
+ */
15
+ export type SessionKind = "managed-repo" | "managed-companion" | "unmanaged";
16
+
17
+ export interface ClaudeDeliveryPlan {
18
+ /** Capability hook groups; `launchSettingsExclude` is what `--settings` must not repeat. */
19
+ readonly hooks: {
20
+ readonly owner: "wrap" | "settings" | "none";
21
+ readonly launchSettingsExclude: readonly ClaudeHookGroup[];
22
+ };
23
+ /** Mate MCP servers. */
24
+ readonly mcp: "wrap" | "mcp-config" | "none";
25
+ /** Companion directory access. */
26
+ readonly dirAccess: "wrap" | "add-dir" | "none";
27
+ /** Wrap reconciliation. */
28
+ readonly reconcile: "attended-prepare" | "prepare-hook" | "none";
29
+ /** Companion Git synchronization. */
30
+ readonly gitSync: "launch-preflight" | "prepare-hook" | "banner";
31
+ }
32
+
33
+ export function planClaudeDelivery(kind: SessionKind, wrap: WrapState): ClaudeDeliveryPlan {
34
+ switch (kind) {
35
+ case "managed-repo":
36
+ return {
37
+ hooks: { owner: "wrap", launchSettingsExclude: wrap.carriedClaudeHookGroups },
38
+ mcp: "wrap",
39
+ dirAccess: "wrap",
40
+ reconcile: "attended-prepare",
41
+ gitSync: "launch-preflight",
42
+ };
43
+ case "managed-companion":
44
+ return {
45
+ hooks: { owner: "settings", launchSettingsExclude: [] },
46
+ mcp: "mcp-config",
47
+ dirAccess: "add-dir",
48
+ reconcile: "none",
49
+ gitSync: "launch-preflight",
50
+ };
51
+ case "unmanaged":
52
+ return wrap.runtimes.has("claude")
53
+ ? {
54
+ hooks: { owner: "wrap", launchSettingsExclude: [] },
55
+ mcp: "wrap",
56
+ dirAccess: "wrap",
57
+ reconcile: "prepare-hook",
58
+ gitSync: "prepare-hook",
59
+ }
60
+ : {
61
+ hooks: { owner: "none", launchSettingsExclude: [] },
62
+ mcp: "none",
63
+ dirAccess: "none",
64
+ reconcile: "none",
65
+ gitSync: "banner",
66
+ };
67
+ }
68
+ }
69
+
70
+ /** For session-start hooks: a plain session's plan from a lenient read; no root, not wrapped. */
71
+ export function unmanagedClaudeDelivery(repoRoot: string | undefined): ClaudeDeliveryPlan {
72
+ return planClaudeDelivery("unmanaged", repoRoot ? readWrapStateSync(repoRoot) : NOT_WRAPPED);
73
+ }