@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4

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 (127) 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/add-dir.ts +10 -76
  5. package/opencode-plugin/src/audience-layer.ts +91 -0
  6. package/opencode-plugin/src/companion.ts +70 -18
  7. package/opencode-plugin/src/launch-environment.ts +29 -0
  8. package/opencode-plugin/src/server.ts +60 -8
  9. package/package.json +3 -3
  10. package/src/cli/commands/artifact/finish/openspec.ts +1 -1
  11. package/src/cli/commands/artifact/pending/discovery.ts +60 -21
  12. package/src/cli/commands/cap/index-cmd.ts +2 -11
  13. package/src/cli/commands/companion/link.ts +16 -11
  14. package/src/cli/commands/companion/list.ts +4 -5
  15. package/src/cli/commands/companion/open.ts +4 -5
  16. package/src/cli/commands/companion/tui.ts +1 -1
  17. package/src/cli/commands/doctor.ts +87 -5
  18. package/src/cli/commands/install.ts +8 -0
  19. package/src/cli/commands/report/renderer.ts +9 -19
  20. package/src/cli/commands/session/prepare.ts +119 -0
  21. package/src/cli/commands/shared/companion-selection.ts +2 -2
  22. package/src/cli/commands/studio/reports.ts +0 -1
  23. package/src/cli/commands/studio/server.ts +6 -2
  24. package/src/cli/commands/studio/terminal-launch.ts +16 -7
  25. package/src/cli/commands/unwrap.ts +28 -3
  26. package/src/cli/commands/wrap.ts +79 -60
  27. package/src/cli/companion-selector.tsx +1 -1
  28. package/src/cli/main.ts +17 -0
  29. package/src/cli/usage.ts +5 -2
  30. package/src/hooks/session-banner.ts +19 -10
  31. package/src/hooks/session-guidance.ts +20 -2
  32. package/src/hooks/session-prepare.ts +101 -0
  33. package/src/lib/fs-utils.ts +13 -0
  34. package/src/lib/orchestrator/adapters/base.ts +45 -9
  35. package/src/lib/orchestrator/adapters/claude.ts +241 -30
  36. package/src/lib/orchestrator/adapters/opencode.ts +72 -34
  37. package/src/lib/orchestrator/companion-git-sync.ts +28 -1
  38. package/src/lib/orchestrator/companion-hub.ts +2 -1
  39. package/src/lib/orchestrator/config-store.ts +83 -1
  40. package/src/lib/orchestrator/framework-context.ts +5 -29
  41. package/src/lib/orchestrator/launcher.ts +36 -171
  42. package/src/lib/orchestrator/openspec-store.ts +202 -0
  43. package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
  44. package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
  45. package/src/lib/orchestrator/projection-entries.ts +124 -11
  46. package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
  47. package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
  48. package/src/lib/orchestrator/projection-record.ts +7 -1
  49. package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
  50. package/src/lib/orchestrator/projection-types.ts +47 -34
  51. package/src/lib/orchestrator/repository-link.ts +473 -0
  52. package/src/lib/orchestrator/root-context.ts +6 -2
  53. package/src/lib/orchestrator/session-prepare.ts +503 -0
  54. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  55. package/src/lib/orchestrator/setup-preflight.ts +3 -1
  56. package/src/lib/orchestrator/working-repo-projection.ts +73 -160
  57. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  58. package/src/lib/orchestrator/wrap-pass.ts +222 -0
  59. package/src/lib/runtime-directory-refresh.ts +186 -0
  60. package/src/lib/update-checker.ts +6 -0
  61. package/src/opencode/companion-hooks.ts +30 -135
  62. package/src/opencode/companion-policy.ts +26 -10
  63. package/src/opencode/index.ts +1 -7
  64. package/src/opencode/projected-guidance.ts +6 -13
  65. package/src/opencode/session-notes.ts +76 -0
  66. package/src/opencode/session-prepare.ts +139 -0
  67. package/src/opencode/tui.tsx +56 -9
  68. package/src/runtime/audience-layer.ts +84 -0
  69. package/src/runtime/claude-delivery-plan.ts +73 -0
  70. package/src/runtime/claude-hook-group.ts +21 -0
  71. package/src/runtime/companion-lock.ts +121 -0
  72. package/src/runtime/companion-sync.ts +66 -9
  73. package/src/runtime/guidance.ts +17 -0
  74. package/src/runtime/opencode-audience-layer.ts +176 -0
  75. package/src/runtime/policy.ts +14 -1
  76. package/src/runtime/projection.ts +1 -1
  77. package/src/runtime/runtime-directory.ts +43 -0
  78. package/src/runtime/runtime-document-manifest.ts +352 -0
  79. package/src/runtime/session-prepare-runner.ts +148 -0
  80. package/src/runtime/wrap-state.ts +94 -0
  81. package/src/runtime/write-atomic.ts +27 -0
  82. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  83. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
  84. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
  85. package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
  86. package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
  87. package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
  88. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
  89. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
  90. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
  91. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
  92. package/src/tools/setup/audience-isolation.fixture.ts +325 -0
  93. package/src/tools/setup/audience-layer.ts +207 -0
  94. package/src/tools/setup/audience-registrations.ts +52 -0
  95. package/src/tools/setup/capabilities/openspec.ts +9 -0
  96. package/src/tools/setup/capabilities/react-doctor.ts +34 -50
  97. package/src/tools/setup/capabilities/tokensave.ts +11 -0
  98. package/src/tools/setup/contribution-ledger.ts +207 -0
  99. package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
  100. package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
  101. package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
  102. package/src/tools/setup/engine.ts +152 -10
  103. package/src/tools/setup/isolation-diagnostics.ts +139 -0
  104. package/src/tools/setup/mate.ts +1 -1
  105. package/src/tools/setup/plugin.ts +19 -4
  106. package/src/tools/setup/plugins/gitignore.ts +7 -1
  107. package/src/tools/setup/providers/claude-format.ts +11 -18
  108. package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
  109. package/src/tools/setup/providers/claude.ts +50 -8
  110. package/src/tools/setup/providers/opencode.ts +10 -0
  111. package/src/tools/setup/registry.ts +24 -2
  112. package/src/tools/setup/runtime-documents.ts +152 -32
  113. package/src/tools/setup/surface-target.ts +13 -4
  114. package/src/tools/setup/upgrade-sweep.ts +153 -0
  115. package/src/tools/setup/working-repo-cleanup.ts +2 -0
  116. package/src/tools/setup/working-repo-local-state.ts +91 -14
  117. package/src/tools/setup.ts +11 -8
  118. package/wrappers/bin/openspec +42 -1
  119. package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
  120. package/src/lib/orchestrator/companion-resolver.ts +0 -179
  121. package/src/lib/orchestrator/companion-store.ts +0 -94
  122. package/src/lib/orchestrator/repo-local-registry.ts +0 -143
  123. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
  124. package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
  125. package/src/templates/report-assets/README.md +0 -32
  126. package/src/templates/report-assets/mermaid.LICENSE +0 -21
  127. package/src/templates/report-assets/mermaid.min.js +0 -4376
@@ -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,84 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { FRAMEWORK_NAME } from "./framework";
6
+ import { mateHome } from "./runtime-directory";
7
+
8
+ /**
9
+ * Audience Layer locations. A layer holds one audience's plugin contributions
10
+ * for one companion, rendered for managed launches only. It lives under Mate's
11
+ * machine-local state, never inside a repository: a layer inside the companion
12
+ * would be readable by every session with companion access, and could carry
13
+ * values resolved from plugin configuration.
14
+ *
15
+ * <state>/audience-layers/<companion-key>/<audience>/
16
+ * <fingerprint>/ one complete render; never changed once renamed into place
17
+ * current symlink to the newest complete render
18
+ *
19
+ * A launch attaches the fingerprint directory itself, so a later re-render
20
+ * never changes what a running session reads.
21
+ */
22
+
23
+ /** Mirrors `AUDIENCE_NAME_PATTERN`; repeated here because `runtime/` imports nothing from core. */
24
+ const SAFE_AUDIENCE = /^[a-z][a-z0-9-]{0,63}$/;
25
+
26
+ export const CURRENT_LAYER_LINK = "current";
27
+
28
+ export function audienceLayersRoot(homeDir?: string): string {
29
+ return path.join(mateHome(homeDir), "state", "audience-layers");
30
+ }
31
+
32
+ /** A short, stable key for one companion checkout: a hash of its resolved path. */
33
+ export function companionLayerKey(companionPath: string): string {
34
+ return crypto.createHash("sha256").update(path.resolve(companionPath)).digest("hex").slice(0, 16);
35
+ }
36
+
37
+ /** The directory holding one audience's layers for one companion. */
38
+ export function audienceLayerDir(
39
+ companionPath: string,
40
+ audience: string,
41
+ homeDir?: string,
42
+ ): string {
43
+ if (!SAFE_AUDIENCE.test(audience)) {
44
+ throw new Error(`"${audience}" is not a valid audience name for a layer directory`);
45
+ }
46
+ return path.join(audienceLayersRoot(homeDir), companionLayerKey(companionPath), audience);
47
+ }
48
+
49
+ /** The newest complete layer of `audience`, or `null` when none was rendered on this machine. */
50
+ export function currentAudienceLayer(
51
+ companionPath: string,
52
+ audience: string,
53
+ homeDir?: string,
54
+ ): string | null {
55
+ try {
56
+ const dir = audienceLayerDir(companionPath, audience, homeDir);
57
+ const layer = path.join(dir, fs.readlinkSync(path.join(dir, CURRENT_LAYER_LINK)));
58
+ return fs.existsSync(layer) ? layer : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ const MANAGED_MARKER = new RegExp(
65
+ `^<!-- ${FRAMEWORK_NAME}:managed:[^\\n]* (?:start|end) -->$`,
66
+ "gm",
67
+ );
68
+
69
+ /** A layer guidance document's text, managed-block markers removed; empty when absent. */
70
+ export function layerGuidanceText(text: string): string {
71
+ return text
72
+ .replace(MANAGED_MARKER, "")
73
+ .replace(/\n{3,}/g, "\n\n")
74
+ .trim();
75
+ }
76
+
77
+ /** `file` (`CLAUDE.md`, `AGENTS.md`) of the layer at `root`, markers removed; empty when absent. */
78
+ export function readLayerGuidanceSync(root: string, file: string): string {
79
+ try {
80
+ return layerGuidanceText(fs.readFileSync(path.join(root, file), "utf8"));
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Claude hook-group shape and its one identity. A `runtime/` leaf so hooks and
3
+ * Wrap State can compare groups without loading setup code.
4
+ */
5
+
6
+ export interface ClaudeHookCommand {
7
+ type?: string;
8
+ command?: string;
9
+ args?: string[];
10
+ timeout?: number;
11
+ }
12
+
13
+ export interface ClaudeHookGroup {
14
+ matcher?: string;
15
+ hooks?: ClaudeHookCommand[];
16
+ }
17
+
18
+ /** The one hook-group identity: structurally identical groups are the same group. */
19
+ export function hookGroupKey(group: ClaudeHookGroup): string {
20
+ return JSON.stringify(group);
21
+ }
@@ -0,0 +1,121 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import crypto from "node:crypto";
3
+ import fsSync from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ import { mateHome } from "./runtime-directory";
8
+
9
+ /**
10
+ * Per-companion lock under `<mate home>/locks/`, outside the companion's tree;
11
+ * repos share a companion, so a per-repo lock would race pulls. Re-entrant per
12
+ * async call chain, not per process.
13
+ */
14
+
15
+ export interface CompanionLockOptions {
16
+ homeDir?: string;
17
+ /** `false` tries once and returns `acquired: false` at once when the lock is held. */
18
+ wait?: boolean;
19
+ /** How long to wait for another holder before giving up. */
20
+ timeoutMs?: number;
21
+ pollMs?: number;
22
+ /** Called once, when the lock is found held and the caller starts waiting. */
23
+ onWait?: () => void;
24
+ }
25
+
26
+ export type CompanionLockResult<T> = { acquired: true; value: T } | { acquired: false };
27
+
28
+ function companionKey(companionPath: string): string {
29
+ let resolved = path.resolve(companionPath);
30
+ try {
31
+ resolved = fsSync.realpathSync(resolved);
32
+ } catch {
33
+ /* an unresolvable companion is still keyed by its spelling */
34
+ }
35
+ return crypto.createHash("sha256").update(resolved).digest("hex").slice(0, 32);
36
+ }
37
+
38
+ export function companionLockPath(companionPath: string, homeDir?: string): string {
39
+ return path.join(mateHome(homeDir), "locks", `${companionKey(companionPath)}.lock`);
40
+ }
41
+
42
+ const heldLocks = new AsyncLocalStorage<ReadonlySet<string>>();
43
+
44
+ /** An unreadable holder id is only trusted this long, so a corrupt lock cannot block forever. */
45
+ const UNREADABLE_LOCK_GRACE_MS = 10_000;
46
+
47
+ async function holderAlive(lockPath: string, raw: string): Promise<boolean> {
48
+ const pid = Number.parseInt(raw.trim(), 10);
49
+ if (!Number.isInteger(pid) || pid <= 0) {
50
+ const stat = await fs.stat(lockPath).catch(() => null);
51
+ return stat !== null && Date.now() - stat.mtimeMs < UNREADABLE_LOCK_GRACE_MS;
52
+ }
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ return (error as NodeJS.ErrnoException).code === "EPERM";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The holder's pid is written to a private file first and hard-linked into
63
+ * place, so the lock never exists without its holder: a contender reading an
64
+ * empty file in between would otherwise take it as stale and remove it.
65
+ */
66
+ async function tryAcquire(lockPath: string): Promise<boolean> {
67
+ const tempPath = `${lockPath}.${crypto.randomUUID()}.tmp`;
68
+ await fs.writeFile(tempPath, String(process.pid));
69
+ try {
70
+ await fs.link(tempPath, lockPath);
71
+ return true;
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
74
+ } finally {
75
+ await fs.rm(tempPath, { force: true });
76
+ }
77
+ const raw = await fs.readFile(lockPath, "utf8").catch(() => null);
78
+ if (raw === null || (await holderAlive(lockPath, raw))) return false;
79
+ /** A dead holder's lock is removed only if it is still the one just read. */
80
+ if ((await fs.readFile(lockPath, "utf8").catch(() => null)) === raw) {
81
+ await fs.rm(lockPath, { force: true });
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Runs `task` while holding the companion's lock; `acquired: false` after the
88
+ * bounded wait, or at once with `wait: false`. A caller already holding it in
89
+ * this call chain runs `task` directly.
90
+ */
91
+ export async function withCompanionLock<T>(
92
+ companionPath: string,
93
+ options: CompanionLockOptions,
94
+ task: () => Promise<T>,
95
+ ): Promise<CompanionLockResult<T>> {
96
+ const lockPath = companionLockPath(companionPath, options.homeDir);
97
+ const held = heldLocks.getStore() ?? new Set<string>();
98
+ if (held.has(lockPath)) return { acquired: true, value: await task() };
99
+
100
+ const timeoutMs = options.wait === false ? 0 : (options.timeoutMs ?? 30_000);
101
+ const pollMs = options.pollMs ?? 200;
102
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
103
+
104
+ const deadline = Date.now() + timeoutMs;
105
+ let waiting = false;
106
+ // oxlint-disable-next-line no-await-in-loop -- polling is the point
107
+ while (!(await tryAcquire(lockPath))) {
108
+ if (Date.now() >= deadline) return { acquired: false };
109
+ if (!waiting) {
110
+ waiting = true;
111
+ options.onWait?.();
112
+ }
113
+ // oxlint-disable-next-line no-await-in-loop
114
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
115
+ }
116
+ try {
117
+ return { acquired: true, value: await heldLocks.run(new Set([...held, lockPath]), task) };
118
+ } finally {
119
+ await fs.rm(lockPath, { force: true });
120
+ }
121
+ }
@@ -30,6 +30,7 @@ import {
30
30
  type CompanionForkState,
31
31
  type CompanionGit,
32
32
  } from "./companion-git";
33
+ import { withCompanionLock } from "./companion-lock";
33
34
  import { hasLaunchEnvironment } from "./env";
34
35
  import { FRAMEWORK_NAME } from "./framework";
35
36
  import { readCompanionPolicy } from "./policy";
@@ -37,10 +38,20 @@ import { readCompanionPolicy } from "./policy";
37
38
  /** The one command that finishes what the unattended half declines to do. */
38
39
  export const COMPANION_SYNC_COMMAND = `${FRAMEWORK_NAME} companion sync`;
39
40
 
41
+ /** OpenCode's recovery is the managed launch: it runs the sync at a terminal that can answer Git. */
42
+ export const OPENCODE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} opencode`;
43
+
44
+ /** A Claude-wrapped repository's equivalent: the attended launch reconciles the wrap. */
45
+ export const CLAUDE_LAUNCH_COMMAND = `${FRAMEWORK_NAME} claude`;
46
+
47
+ /** Which runtime's operator reads the note; it decides the recovery named. */
48
+ export type StalenessAudience = "claude" | "opencode";
49
+
40
50
  /** A cold link costs this once; the recorded completion then suppresses retries. */
41
51
  export const COMPANION_SYNC_TIMEOUT_MS = 20_000;
42
52
 
43
- export type UnattendedSyncStatus = "not-applicable" | "fresh" | "complete" | "unfinished";
53
+ /** `busy`: another operation held the companion lock; nothing ran and nothing is owed. */
54
+ export type UnattendedSyncStatus = "not-applicable" | "fresh" | "busy" | "complete" | "unfinished";
44
55
 
45
56
  export interface UnattendedSyncOutcome {
46
57
  status: UnattendedSyncStatus;
@@ -58,6 +69,12 @@ export interface UnattendedSyncOptions {
58
69
  homeDir?: string;
59
70
  /** Environment Git runs with. */
60
71
  env?: Record<string, string | undefined>;
72
+ /**
73
+ * `wait` (the default) queues behind another holder of the companion lock,
74
+ * within the time budget; `try` skips at once, for a hook that must not
75
+ * stall a session start.
76
+ */
77
+ lock?: "wait" | "try";
61
78
  }
62
79
 
63
80
  const UNFINISHED_GIT_PATHS = [
@@ -115,6 +132,24 @@ export async function syncCompanionUnattended(
115
132
  return { status: "fresh", changed: false, companionPath };
116
133
  }
117
134
 
135
+ const locked = await withCompanionLock(
136
+ companionPath,
137
+ { homeDir, wait: options.lock !== "try", timeoutMs },
138
+ () => syncHoldingLock(companionPath, { timeoutMs, now, homeDir, env }),
139
+ );
140
+ return locked.acquired ? locked.value : { status: "busy", changed: false, companionPath };
141
+ }
142
+
143
+ async function syncHoldingLock(
144
+ companionPath: string,
145
+ options: {
146
+ timeoutMs: number;
147
+ now: Date;
148
+ homeDir?: string;
149
+ env?: Record<string, string | undefined>;
150
+ },
151
+ ): Promise<UnattendedSyncOutcome> {
152
+ const { timeoutMs, now, homeDir, env } = options;
118
153
  const git = companionGit(companionPath, {
119
154
  prompt: "never",
120
155
  budgetMs: timeoutMs,
@@ -191,13 +226,36 @@ export async function syncCompanionUnattended(
191
226
  * answer a credential prompt and no way to tell a fast-forward from a
192
227
  * conflicted rebase.
193
228
  */
194
- export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): string[] {
229
+ export function unattendedSyncStalenessLines(
230
+ outcome: UnattendedSyncOutcome,
231
+ audience: StalenessAudience = "claude",
232
+ ): string[] {
195
233
  if (outcome.status !== "unfinished") return [];
234
+ const reason = outcome.reason ?? "unknown reason";
235
+ if (audience === "opencode") return launchRecoveryStalenessLines(outcome, ["opencode"]);
196
236
  return [
197
- `companion Git synchronization unfinished: ${outcome.reason ?? "unknown reason"} — run \`${COMPANION_SYNC_COMMAND}\``,
237
+ `companion Git synchronization unfinished: ${reason} — run \`${COMPANION_SYNC_COMMAND}\``,
198
238
  ];
199
239
  }
200
240
 
241
+ /**
242
+ * The note for a session prepare, naming the managed launch of each wrapped
243
+ * runtime as the recovery: it runs the sync at a terminal that can answer Git.
244
+ */
245
+ export function launchRecoveryStalenessLines(
246
+ outcome: UnattendedSyncOutcome,
247
+ runtimes: readonly StalenessAudience[],
248
+ ): string[] {
249
+ if (outcome.status !== "unfinished") return [];
250
+ const reason = outcome.reason ?? "unknown reason";
251
+ const wrapped = new Set(runtimes);
252
+ const launches = [
253
+ ...(wrapped.has("claude") ? [CLAUDE_LAUNCH_COMMAND] : []),
254
+ ...(wrapped.has("opencode") || wrapped.size === 0 ? [OPENCODE_LAUNCH_COMMAND] : []),
255
+ ].map((command) => `\`${command}\``);
256
+ return [`Companion not updated: ${reason}. Restart with ${launches.join(" or ")} to fix it.`];
257
+ }
258
+
201
259
  /**
202
260
  * The note a session-state reader surfaces, taken from the persisted record so
203
261
  * a render costs no Git. Operator-facing only, exactly as the live note is.
@@ -205,16 +263,15 @@ export function unattendedSyncStalenessLines(outcome: UnattendedSyncOutcome): st
205
263
  export function persistedCompanionGitStalenessLines(
206
264
  companionPath: string,
207
265
  homeDir?: string,
266
+ audience: StalenessAudience = "claude",
208
267
  ): string[] {
209
268
  if (!companionPath) return [];
210
269
  const reason = readCompanionGitRecord(companionPath, homeDir).unfinishedReason;
211
270
  if (!reason) return [];
212
- return unattendedSyncStalenessLines({
213
- status: "unfinished",
214
- changed: false,
215
- companionPath,
216
- reason,
217
- });
271
+ return unattendedSyncStalenessLines(
272
+ { status: "unfinished", changed: false, companionPath, reason },
273
+ audience,
274
+ );
218
275
  }
219
276
 
220
277
  export interface ForkGuardOptions {