@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.
- package/claude-plugin/hooks/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/companion.ts +47 -12
- package/opencode-plugin/src/server.ts +39 -4
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +1 -1
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +53 -59
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +3 -1
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +43 -8
- package/src/lib/orchestrator/adapters/claude.ts +130 -29
- package/src/lib/orchestrator/adapters/opencode.ts +17 -19
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +56 -2
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
- package/src/lib/orchestrator/projection-entries.ts +78 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +38 -33
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +425 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/types.ts +4 -1
- package/src/lib/orchestrator/working-repo-projection.ts +71 -159
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +131 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +24 -132
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +335 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +90 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
- package/src/tools/setup/engine.ts +15 -4
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +23 -6
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/working-repo-local-state.ts +82 -13
- package/src/tools/setup.ts +2 -2
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- 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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
}
|
package/src/opencode/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/opencode/tui.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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({
|
|
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
|
-
{
|
|
78
|
+
{notes.lines.map((note) => (
|
|
58
79
|
<text fg={warning}>{note}</text>
|
|
59
80
|
))}
|
|
60
81
|
</box>
|
|
61
82
|
);
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
|
|
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.
|
|
71
|
-
|
|
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
|
+
}
|