@macintacos/caret 1.1.0 → 1.2.0
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/plugin.json +1 -1
- package/README.md +11 -4
- package/bin/caret-launcher +78 -25
- package/commands/doctor.md +2 -1
- package/dist/cli.js +2253 -904
- package/opencode/caret.core.ts +458 -0
- package/opencode/caret.plugin.ts +83 -456
- package/opencode/caret.plugin.v2.ts +243 -0
- package/opencode/caret.tui.ts +75 -0
- package/opencode/commands/doctor.md +2 -1
- package/opencode/index.ts +6 -5
- package/opencode/permission.ts +102 -0
- package/package.json +2 -1
- package/ui/dist/assets/{angular-html-BECXXPyr.js → angular-html-BVXNFElL.js} +1 -1
- package/ui/dist/assets/{angular-ts-CJKAKY9F.js → angular-ts-BBdNNVOV.js} +1 -1
- package/ui/dist/assets/{apl-CCcfDDqZ.js → apl-DN-9LSnm.js} +1 -1
- package/ui/dist/assets/{astro-DKCnAnVR.js → astro-DuMkehjz.js} +1 -1
- package/ui/dist/assets/{blade-DQ_JdT2N.js → blade-B_5LVqnt.js} +1 -1
- package/ui/dist/assets/{c-D3TwszVj.js → c-5LpWn0DJ.js} +1 -1
- package/ui/dist/assets/{chapel-C-2sPcCX.js → chapel-qw3-lXMw.js} +1 -1
- package/ui/dist/assets/{cobol-BPmo_4f_.js → cobol-BAnr3Azd.js} +1 -1
- package/ui/dist/assets/{coffee-DMTsNDVt.js → coffee-CGRFpAgb.js} +1 -1
- package/ui/dist/assets/{cpp-BJr6_Y8R.js → cpp-olfuBwwP.js} +1 -1
- package/ui/dist/assets/{crystal-pIWESUkD.js → crystal-qe9b46tp.js} +1 -1
- package/ui/dist/assets/{css-DRd9Q4Li.js → css-DkXCh44l.js} +1 -1
- package/ui/dist/assets/{dist-aKlRxpD_.js → dist-B1RcczPQ.js} +1 -1
- package/ui/dist/assets/{dist-BGfsV7n2.js → dist-BnIbJslb.js} +1 -1
- package/ui/dist/assets/{dist-BJ7SLVNC.js → dist-BnbtGh0F.js} +1 -1
- package/ui/dist/assets/{dist-BFQBMwmz.js → dist-CFbPxPI-.js} +1 -1
- package/ui/dist/assets/{dist-DoJTqy3z.js → dist-CHZr2-kE.js} +1 -1
- package/ui/dist/assets/{dist-BNr0c85A.js → dist-CMOjVm1q.js} +1 -1
- package/ui/dist/assets/{dist-C6t67BjE.js → dist-CfHYS987.js} +1 -1
- package/ui/dist/assets/{dist-CU5aMlNo.js → dist-D8lZm-8I.js} +1 -1
- package/ui/dist/assets/{dist-CD25BkNS.js → dist-KWgwFCxG.js} +1 -1
- package/ui/dist/assets/{dist-RKKeH1zs.js → dist-iPRbc51X.js} +1 -1
- package/ui/dist/assets/{edge-DeNk4F2G.js → edge-hytDa3H3.js} +1 -1
- package/ui/dist/assets/{elixir-DHc-DOO8.js → elixir-DnjerbFh.js} +1 -1
- package/ui/dist/assets/{elm-CJRqEcvz.js → elm-Cy60APyX.js} +1 -1
- package/ui/dist/assets/{erb-BEWGTHDA.js → erb-Rclm0zaT.js} +1 -1
- package/ui/dist/assets/{git-rebase-DzlcWhg9.js → git-rebase-Be7W4GuM.js} +1 -1
- package/ui/dist/assets/{glimmer-js-B-aWCRIZ.js → glimmer-js-DAN9CyDo.js} +1 -1
- package/ui/dist/assets/{glimmer-ts-DcjmPyQn.js → glimmer-ts-Bd4Ltw2M.js} +1 -1
- package/ui/dist/assets/{glsl-C9FOT-Uv.js → glsl-DIrrPSk8.js} +1 -1
- package/ui/dist/assets/{graphql-BgKSoRp0.js → graphql-CBXqF557.js} +1 -1
- package/ui/dist/assets/{hack-BOedpUUE.js → hack-D_K_3nhB.js} +1 -1
- package/ui/dist/assets/{haml-Cpngw1vd.js → haml-g7mpgDGW.js} +1 -1
- package/ui/dist/assets/{handlebars-CpW1IyBg.js → handlebars-DRWwCyqd.js} +1 -1
- package/ui/dist/assets/{html-CV142D8s.js → html-Byvm88Y2.js} +1 -1
- package/ui/dist/assets/{html-derivative-Df7h1Nuw.js → html-derivative-DpLoxBsM.js} +1 -1
- package/ui/dist/assets/{http-Cneddv9N.js → http-DGq_mf9b.js} +1 -1
- package/ui/dist/assets/{hurl-D6TS55N6.js → hurl-qkfW8ihO.js} +1 -1
- package/ui/dist/assets/{index-Ci9yCNtV.js → index-BwtwrzNC.js} +4 -4
- package/ui/dist/assets/{java-BicD3zQH.js → java-BHSPy0gM.js} +1 -1
- package/ui/dist/assets/{javascript-l6vKHWBV.js → javascript-BGu3ZEma.js} +1 -1
- package/ui/dist/assets/{jinja-BJF2iot2.js → jinja-BkUiOuUL.js} +1 -1
- package/ui/dist/assets/{jison-DiXPFJAT.js → jison-DQ_FQMYl.js} +1 -1
- package/ui/dist/assets/{json-BDRuI4UV.js → json-BHyU93c5.js} +1 -1
- package/ui/dist/assets/{jsx-BvfdkIOy.js → jsx-DhxI-Xjj.js} +1 -1
- package/ui/dist/assets/{julia-bXAhreI5.js → julia-BpY5TdkA.js} +1 -1
- package/ui/dist/assets/{just-BOxCfXDg.js → just-Dy9X30SL.js} +1 -1
- package/ui/dist/assets/{latex-B9OiTBLF.js → latex-DnqjVTTq.js} +1 -1
- package/ui/dist/assets/{liquid-DuLKDeWE.js → liquid-DSLF1wIz.js} +1 -1
- package/ui/dist/assets/{lua-Dbh9FQm_.js → lua-CWQ1tn9q.js} +1 -1
- package/ui/dist/assets/{marko--tjEBgDM.js → marko-CVOaD8Lr.js} +1 -1
- package/ui/dist/assets/{mdc-ByNJpl3m.js → mdc-CorrsEtK.js} +1 -1
- package/ui/dist/assets/{nginx-D4wsmFK9.js → nginx--n2A1zuK.js} +1 -1
- package/ui/dist/assets/{nim-Cy8RxisP.js → nim-eb5p3yB7.js} +1 -1
- package/ui/dist/assets/{org-CAm7o61d.js → org-Cnlel0O3.js} +1 -1
- package/ui/dist/assets/{perl-BAvaFGKe.js → perl-DDWGMuBV.js} +1 -1
- package/ui/dist/assets/{php-DpAf3w7u.js → php-Bz2SRtKk.js} +1 -1
- package/ui/dist/assets/{pug-Dm7otyJU.js → pug-cGPMemK2.js} +1 -1
- package/ui/dist/assets/{qml-D4rWQQKC.js → qml-B4Hbmvdi.js} +1 -1
- package/ui/dist/assets/{r-DMKUX8qf.js → r-BuuNGonz.js} +1 -1
- package/ui/dist/assets/{razor-BIUJkuf_.js → razor-CHpWr94T.js} +1 -1
- package/ui/dist/assets/{regexp-D8W_pVHj.js → regexp-9s1znDWq.js} +1 -1
- package/ui/dist/assets/{rst-aFr0DP5H.js → rst-BBsXWDuP.js} +1 -1
- package/ui/dist/assets/{ruby-BzH6kUKr.js → ruby-pQqViN30.js} +1 -1
- package/ui/dist/assets/{sas-CDk9DyCV.js → sas-C4SSIJLx.js} +1 -1
- package/ui/dist/assets/{scss-DABbvU5W.js → scss-D8e130Cq.js} +1 -1
- package/ui/dist/assets/{shellscript-B0ObP3K-.js → shellscript-CHUzbqv1.js} +1 -1
- package/ui/dist/assets/{shellsession-2sv3Jq0j.js → shellsession-BulTL_bH.js} +1 -1
- package/ui/dist/assets/{soy-irSRV4oq.js → soy-zGj4V7Ec.js} +1 -1
- package/ui/dist/assets/{sql-DJCHoKvP.js → sql-CnJYFgwb.js} +1 -1
- package/ui/dist/assets/{stata-a8YnzbIc.js → stata-BXDojqs4.js} +1 -1
- package/ui/dist/assets/{surrealql-DdXPOg8s.js → surrealql-3hRFCpnH.js} +1 -1
- package/ui/dist/assets/{svelte-ClAKUEZR.js → svelte-C8TzlKVd.js} +1 -1
- package/ui/dist/assets/{templ-NUCvvUBA.js → templ-D5KJNb8h.js} +1 -1
- package/ui/dist/assets/{tex-DwZeSAfC.js → tex-szX76TU8.js} +1 -1
- package/ui/dist/assets/{ts-tags-CFJEHYY_.js → ts-tags-H1v1spsZ.js} +1 -1
- package/ui/dist/assets/{tsx-B934yX2T.js → tsx-CoyAoKjY.js} +1 -1
- package/ui/dist/assets/{twig-NFkkOj3w.js → twig-pqXRFgM9.js} +1 -1
- package/ui/dist/assets/{typescript-7bmMC_xU.js → typescript-CNl_4SRH.js} +1 -1
- package/ui/dist/assets/{typst-BQEjp9tn.js → typst-Dr_K1Jqf.js} +1 -1
- package/ui/dist/assets/{vue-JqD9ieOF.js → vue-Wl1CPRGX.js} +1 -1
- package/ui/dist/assets/{vue-html-CT4XQRX1.js → vue-html-F5NdFsoh.js} +1 -1
- package/ui/dist/assets/{vue-vine-DIR27Dmn.js → vue-vine-YWSKUX3v.js} +1 -1
- package/ui/dist/assets/{xml-DhsMbw-1.js → xml-crSJzq2-.js} +1 -1
- package/ui/dist/assets/{xsl-BvqsVUg9.js → xsl-D-YjJFxJ.js} +1 -1
- package/ui/dist/assets/{yaml-C8eWjK_Q.js → yaml-BZWjhwN4.js} +1 -1
- package/ui/dist/index.html +1 -1
package/opencode/caret.plugin.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// tool's execute() by spawning `caret review` (CARET_AGENT=opencode). The whole
|
|
7
7
|
// caret daemon/review pipeline is reused unchanged — this plugin is the
|
|
8
8
|
// OpenCode-side counterpart to Claude Code's hooks.json, which likewise spawns
|
|
9
|
-
// `caret review`.
|
|
9
|
+
// `caret review`. Its host-neutral parts live in caret.core.ts, shared with
|
|
10
|
+
// caret.plugin.v2.ts and caret.tui.ts.
|
|
10
11
|
//
|
|
11
12
|
// Subagent-bypass mitigation: OpenCode's tool.execute.before does not fire for
|
|
12
13
|
// subagent tool calls, so caret does NOT rely on a hook to gate subagents. The
|
|
@@ -15,108 +16,49 @@
|
|
|
15
16
|
// tool body re-checks the calling session's shape — defense in depth. Any PRIMARY
|
|
16
17
|
// agent may ask for a review; only the plan agent is steered toward it.
|
|
17
18
|
//
|
|
18
|
-
// It ships in the @macintacos/caret npm package
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// builtins,
|
|
23
|
-
// at runtime). Which ctx/tool/config shapes are
|
|
19
|
+
// It ships in the @macintacos/caret npm package. `caret install` lists the package
|
|
20
|
+
// under v1's `plugin` config key or v2's `plugins` key (v2 reads both, legacy `plugin`
|
|
21
|
+
// first). OpenCode imports the package entry, index.ts, which re-exports this file's
|
|
22
|
+
// default; it withholds v2's caret.plugin.v2.ts and caret.tui.ts for now.
|
|
23
|
+
// Its imports are node builtins, caret.core.ts, review-bridge.ts, and
|
|
24
|
+
// @opencode-ai/plugin (resolved by OpenCode at runtime). Which ctx/tool/config shapes are
|
|
25
|
+
// live-verified and which are not:
|
|
24
26
|
// doc/agents/opencode-integration.md § Verified vs. follow-up.
|
|
25
27
|
|
|
26
|
-
import {
|
|
27
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
28
29
|
import { homedir } from "node:os";
|
|
29
|
-
import {
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { relative } from "node:path";
|
|
31
31
|
|
|
32
32
|
import { type Hooks, type Plugin, tool } from "@opencode-ai/plugin";
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
marker: string;
|
|
56
|
-
importMetaUrl: string;
|
|
57
|
-
}): string {
|
|
58
|
-
const override = opts.env.CARET_OPENCODE_BIN?.trim();
|
|
59
|
-
if (override) return override;
|
|
60
|
-
if (opts.marker !== "__CARET_BIN__") return opts.marker;
|
|
61
|
-
return fileURLToPath(new URL("../bin/caret", opts.importMetaUrl));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The plugin's own caret version, for the update check. A substituted marker wins
|
|
65
|
-
* (file-deploy); else read it from the package.json shipped beside this module (the
|
|
66
|
-
* array install). "unknown" when neither is available — deliberately UNPARSEABLE so
|
|
67
|
-
* `isNewer` compares false and a broken read stays silent ("0.0.0" would parse and
|
|
68
|
-
* nag "update available (you have 0.0.0)" on every start). */
|
|
69
|
-
export function resolveCaretVersion(opts: {
|
|
70
|
-
marker: string;
|
|
71
|
-
importMetaUrl: string;
|
|
72
|
-
readFile: (path: string) => string;
|
|
73
|
-
}): string {
|
|
74
|
-
if (opts.marker !== "__CARET_VERSION__") return opts.marker;
|
|
75
|
-
try {
|
|
76
|
-
const raw = opts.readFile(fileURLToPath(new URL("../package.json", opts.importMetaUrl)));
|
|
77
|
-
const v = (JSON.parse(raw) as { version?: unknown }).version;
|
|
78
|
-
return typeof v === "string" ? v : "unknown";
|
|
79
|
-
} catch {
|
|
80
|
-
return "unknown";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** The plan-review tool the Plan agent calls. */
|
|
85
|
-
export const REVIEW_TOOL = "caret_review_plan";
|
|
86
|
-
|
|
87
|
-
/** OpenCode's built-in primary planning agent — the one agent caret steers toward
|
|
88
|
-
* the review tool and warms the daemon for. */
|
|
89
|
-
export const PLANNING_AGENTS = ["plan"] as const;
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Pure helpers (exported for unit tests)
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
/** True for the planning agent(s) caret treats specially — the planning steer and
|
|
96
|
-
* the daemon warm-up both fire only for them. Not a permission check: the review
|
|
97
|
-
* tool itself is open to every primary agent. */
|
|
98
|
-
export function isPlanningAgent(agent: string | undefined): boolean {
|
|
99
|
-
return agent !== undefined && (PLANNING_AGENTS as readonly string[]).includes(agent);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The label caret puts in the review toast's TITLE. The review URL goes in the
|
|
103
|
-
* toast MESSAGE alone, not concatenated after this — OpenCode's toast word-wraps,
|
|
104
|
-
* and a URL sharing a line with this prefix breaks across the wrap and stops being
|
|
105
|
-
* terminal-clickable (EXC-691). caret owns this toast surface because OpenCode
|
|
106
|
-
* renders `caret_review_plan` as a generic tool whose running state never shows the
|
|
107
|
-
* tool's `metadata` title. */
|
|
108
|
-
const REVIEW_TOAST_TITLE = "caret: review this plan";
|
|
35
|
+
decisionToast,
|
|
36
|
+
isPlanningAgent,
|
|
37
|
+
nodeWarmRunner,
|
|
38
|
+
PLAN_ARG_DESCRIPTION,
|
|
39
|
+
PLANNING_AGENTS,
|
|
40
|
+
pathArgDescription,
|
|
41
|
+
planningSteer,
|
|
42
|
+
productionUpdateCheck,
|
|
43
|
+
REVIEW_TOOL,
|
|
44
|
+
REVIEW_TOOL_DESCRIPTION,
|
|
45
|
+
resolveCaretBin,
|
|
46
|
+
resolvePlansDir,
|
|
47
|
+
reviewLinkToast,
|
|
48
|
+
runPlanReview,
|
|
49
|
+
type ToastBody,
|
|
50
|
+
type ToastSink,
|
|
51
|
+
toastBestEffort,
|
|
52
|
+
type WarmRunner,
|
|
53
|
+
} from "./caret.core.ts";
|
|
54
|
+
import { nodeSpawnRunner, type SpawnRunner } from "./review-bridge.ts";
|
|
109
55
|
|
|
110
56
|
/** OpenCode's plugin client, structurally narrowed to the calls caret makes.
|
|
111
57
|
* A structural type (rather than importing the SDK client) keeps this robust
|
|
112
58
|
* against version skew between the pinned plugin SDK and the running OpenCode. */
|
|
113
|
-
type
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
variant: "info" | "success" | "warning" | "error";
|
|
117
|
-
duration?: number;
|
|
118
|
-
};
|
|
119
|
-
type ToastClient = { tui?: { showToast?: (opts: { body: ToastBody }) => unknown } } | undefined;
|
|
59
|
+
export type ToastClient =
|
|
60
|
+
| { tui?: { showToast?: (opts: { body: ToastBody }) => unknown } }
|
|
61
|
+
| undefined;
|
|
120
62
|
|
|
121
63
|
/** The same client, narrowed to the session read the review tool's subagent check
|
|
122
64
|
* makes — narrowed separately, and for the same skew-safety reason, so each helper
|
|
@@ -128,26 +70,12 @@ type SessionClient =
|
|
|
128
70
|
/** What OpenCode actually hands the plugin: both narrowings at once. */
|
|
129
71
|
type CaretClient = NonNullable<ToastClient> & NonNullable<SessionClient>;
|
|
130
72
|
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
const REVIEW_TOAST_MS = 10 * 60_000;
|
|
135
|
-
/** The brief decision toast that supersedes (and thereby clears) the review-link
|
|
136
|
-
* toast, since OpenCode's single-slot toast surface has no hide API. */
|
|
137
|
-
const DECISION_TOAST_MS = 4_000;
|
|
138
|
-
|
|
139
|
-
/** Best-effort toast: surfacing or clearing the review link must never crash or
|
|
140
|
-
* delay the review. Swallows a missing method (SDK skew) and any sync throw or
|
|
141
|
-
* async rejection. Called as `tui.showToast(...)` so the SDK client keeps its
|
|
142
|
-
* `this` binding. */
|
|
143
|
-
function showToast(client: ToastClient, body: ToastBody): void {
|
|
73
|
+
/** v1's toast sink, through `toastBestEffort`. A missing method (SDK skew) is a no-op.
|
|
74
|
+
* Called as `tui.showToast(...)` so the SDK client keeps its `this` binding. */
|
|
75
|
+
export function showToast(client: ToastClient, body: ToastBody): void {
|
|
144
76
|
const tui = client?.tui;
|
|
145
77
|
if (!tui || typeof tui.showToast !== "function") return;
|
|
146
|
-
|
|
147
|
-
Promise.resolve(tui.showToast({ body })).catch(() => {});
|
|
148
|
-
} catch {
|
|
149
|
-
// best-effort — the review decision is what matters.
|
|
150
|
-
}
|
|
78
|
+
toastBestEffort((b) => tui.showToast?.({ body: b }), body);
|
|
151
79
|
}
|
|
152
80
|
|
|
153
81
|
/** True when this tool call arrived from a subagent — a `parentID` on the calling
|
|
@@ -170,239 +98,6 @@ async function isSubagentSession(client: SessionClient, sessionID: string): Prom
|
|
|
170
98
|
}
|
|
171
99
|
}
|
|
172
100
|
|
|
173
|
-
type PlanSource = { plan: string; planFilePath?: string };
|
|
174
|
-
|
|
175
|
-
/** The plan the review tool submits, from exactly one of its `plan` / `path` args (an
|
|
176
|
-
* empty string counts as absent). A `path` resolves against the session directory and
|
|
177
|
-
* must be a readable `.md` file — mirroring the core's `isPlanFile`
|
|
178
|
-
* (src/plan/canonical-file.ts), repeated here because this plugin cannot import src/.
|
|
179
|
-
* `readFile` returns the text, or undefined when absPath is not a readable regular
|
|
180
|
-
* file. */
|
|
181
|
-
export function resolvePlanSource(
|
|
182
|
-
args: { plan?: string; path?: string },
|
|
183
|
-
directory: string,
|
|
184
|
-
readFile: (absPath: string) => string | undefined,
|
|
185
|
-
): PlanSource | { error: string } {
|
|
186
|
-
const plan = args.plan || undefined;
|
|
187
|
-
const path = args.path || undefined;
|
|
188
|
-
const notExactlyOne = {
|
|
189
|
-
error: `caret: ${REVIEW_TOOL} takes exactly one of \`plan\` or \`path\`. Pass the plan file as \`path\`, or the plan inline as \`plan\`.`,
|
|
190
|
-
};
|
|
191
|
-
if (path === undefined) return plan === undefined ? notExactlyOne : { plan };
|
|
192
|
-
if (plan !== undefined) return notExactlyOne;
|
|
193
|
-
const planFilePath = resolve(directory, path);
|
|
194
|
-
if (!planFilePath.endsWith(".md")) {
|
|
195
|
-
return {
|
|
196
|
-
error: `caret: ${REVIEW_TOOL} needs a markdown (.md) file as \`path\`, not ${planFilePath}. Write the plan to a .md file, or pass it inline as \`plan\`.`,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
const text = readFile(planFilePath);
|
|
200
|
-
if (text === undefined) {
|
|
201
|
-
return {
|
|
202
|
-
error: `caret: ${REVIEW_TOOL} could not read ${planFilePath} as a regular file. Write the plan there first, or pass it inline as \`plan\`.`,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return { plan: text, planFilePath };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function readRegularFile(absPath: string): string | undefined {
|
|
209
|
-
try {
|
|
210
|
-
return statSync(absPath).isFile() ? readFileSync(absPath, "utf-8") : undefined;
|
|
211
|
-
} catch {
|
|
212
|
-
return undefined;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// --- version-check toast (EXC-794) -----------------------------------------
|
|
217
|
-
|
|
218
|
-
/** caret's latest-release endpoint. Unauthenticated GitHub API — throttled to at
|
|
219
|
-
* most once a day (see UPDATE_CHECK_INTERVAL_MS), so it never approaches the
|
|
220
|
-
* 60 req/hr/IP limit. */
|
|
221
|
-
const LATEST_RELEASE_URL = "https://api.github.com/repos/macintacos/caret/releases/latest";
|
|
222
|
-
/** How long the update nudge stays up — long enough to read the link. */
|
|
223
|
-
const UPDATE_TOAST_MS = 5_000;
|
|
224
|
-
/** Minimum gap between update checks. The nudge is a convenience, not a security
|
|
225
|
-
* fix, so checking once a day is plenty — and it keeps the network hit off every
|
|
226
|
-
* OpenCode start. */
|
|
227
|
-
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
|
|
228
|
-
|
|
229
|
-
/** Semver triple `[major, minor, patch]`, or null when `v` is not `X.Y.Z` (an
|
|
230
|
-
* optional leading `v` is stripped; trailing prerelease/build metadata ignored). */
|
|
231
|
-
function parseVersionTriple(v: string): [number, number, number] | null {
|
|
232
|
-
const m = v
|
|
233
|
-
.trim()
|
|
234
|
-
.replace(/^v/, "")
|
|
235
|
-
.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
236
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** True when `latest` is a strictly higher semver than `current`. Inline (no semver
|
|
240
|
-
* dep) so the deployed plugin stays self-contained; an unparseable version on
|
|
241
|
-
* either side compares false, so the check never nags on something it can't read. */
|
|
242
|
-
export function isNewer(latest: string, current: string): boolean {
|
|
243
|
-
const a = parseVersionTriple(latest);
|
|
244
|
-
const b = parseVersionTriple(current);
|
|
245
|
-
if (!a || !b) return false;
|
|
246
|
-
const [a0, a1, a2] = a;
|
|
247
|
-
const [b0, b1, b2] = b;
|
|
248
|
-
if (a0 !== b0) return a0 > b0;
|
|
249
|
-
if (a1 !== b1) return a1 > b1;
|
|
250
|
-
return a2 > b2;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** The version (v-stripped) and release-page URL from GitHub's /releases/latest
|
|
254
|
-
* JSON, or null when the shape is unusable. */
|
|
255
|
-
export function parseLatestRelease(json: unknown): { version: string; url: string } | null {
|
|
256
|
-
if (typeof json !== "object" || json === null) return null;
|
|
257
|
-
const o = json as { tag_name?: unknown; html_url?: unknown };
|
|
258
|
-
if (typeof o.tag_name !== "string") return null;
|
|
259
|
-
const url =
|
|
260
|
-
typeof o.html_url === "string"
|
|
261
|
-
? o.html_url
|
|
262
|
-
: "https://github.com/macintacos/caret/releases/latest";
|
|
263
|
-
return { version: o.tag_name.replace(/^v/, ""), url };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** The update-available toast body, or null when the user is already current. */
|
|
267
|
-
export function updateToastBody(
|
|
268
|
-
current: string,
|
|
269
|
-
latest: { version: string; url: string },
|
|
270
|
-
): ToastBody | null {
|
|
271
|
-
if (!isNewer(latest.version, current)) return null;
|
|
272
|
-
return {
|
|
273
|
-
title: "caret update available",
|
|
274
|
-
message: `caret ${latest.version} is available (you have ${current}). ${latest.url}`,
|
|
275
|
-
variant: "info",
|
|
276
|
-
duration: UPDATE_TOAST_MS,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** The slice of `fetch` the update check needs — narrow so a test can pass a plain
|
|
281
|
-
* stub without reconstructing `fetch`'s `preconnect` sibling. The global `fetch`
|
|
282
|
-
* (and Bun's) is assignable to it. */
|
|
283
|
-
type FetchLike = (
|
|
284
|
-
url: string,
|
|
285
|
-
init?: { headers?: Record<string, string> },
|
|
286
|
-
) => Promise<{ ok: boolean; json: () => Promise<unknown> }>;
|
|
287
|
-
|
|
288
|
-
/** True when the last check is old enough (or absent) to check again. Pure so the
|
|
289
|
-
* 24h throttle is unit-testable without a clock or the filesystem. */
|
|
290
|
-
export function shouldCheckForUpdate(lastCheckMs: number | null, nowMs: number): boolean {
|
|
291
|
-
return lastCheckMs === null || nowMs - lastCheckMs >= UPDATE_CHECK_INTERVAL_MS;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Absolute path of the throttle file holding the last-check epoch-ms. Lives under
|
|
295
|
-
* caret's state dir, beside caret's other small machine-global markers (update-check.json).
|
|
296
|
-
* The plugin stays self-contained and cannot import src/config/paths.ts, so that
|
|
297
|
-
* convention is mirrored here by hand. */
|
|
298
|
-
export function updateCheckCachePath(
|
|
299
|
-
env: Record<string, string | undefined>,
|
|
300
|
-
home: string,
|
|
301
|
-
): string {
|
|
302
|
-
const base = env.XDG_STATE_HOME?.trim() || `${home}/.local/state`;
|
|
303
|
-
return `${base}/caret/opencode-update-check`;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** The throttle seam: read the last-check epoch-ms (null when never / unreadable),
|
|
307
|
-
* and persist a new one. Injected so realUpdateChecker's throttle is testable
|
|
308
|
-
* without touching disk. */
|
|
309
|
-
type UpdateCache = { read: () => number | null; write: (epochMs: number) => void };
|
|
310
|
-
|
|
311
|
-
/** File-backed UpdateCache. Read tolerates a missing or garbage file (→ null, so
|
|
312
|
-
* the check runs); write is best-effort — a failure just means we check again next
|
|
313
|
-
* start rather than crashing plugin load. */
|
|
314
|
-
function fileUpdateCache(path: string): UpdateCache {
|
|
315
|
-
return {
|
|
316
|
-
read: () => {
|
|
317
|
-
try {
|
|
318
|
-
const n = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
319
|
-
return Number.isFinite(n) ? n : null;
|
|
320
|
-
} catch {
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
},
|
|
324
|
-
write: (epochMs) => {
|
|
325
|
-
try {
|
|
326
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
327
|
-
writeFileSync(path, String(epochMs));
|
|
328
|
-
} catch {
|
|
329
|
-
// best-effort — a throttle-file write must never disrupt load.
|
|
330
|
-
}
|
|
331
|
-
},
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Best-effort startup update check: fetch caret's latest GitHub release and, if it
|
|
336
|
-
* is newer than this plugin's version, toast a nudge with a link. Throttled to at
|
|
337
|
-
* most once a day via the injected cache. Never throws and never blocks plugin load
|
|
338
|
-
* — the `CARET_OPENCODE_NO_UPDATE_CHECK` opt-out and every failure resolve
|
|
339
|
-
* silently. */
|
|
340
|
-
export async function realUpdateChecker(
|
|
341
|
-
client: ToastClient,
|
|
342
|
-
opts: {
|
|
343
|
-
currentVersion: string;
|
|
344
|
-
env: Record<string, string | undefined>;
|
|
345
|
-
fetchImpl: FetchLike;
|
|
346
|
-
now: () => number;
|
|
347
|
-
cache: UpdateCache;
|
|
348
|
-
url?: string;
|
|
349
|
-
},
|
|
350
|
-
): Promise<void> {
|
|
351
|
-
if (opts.env.CARET_OPENCODE_NO_UPDATE_CHECK) return;
|
|
352
|
-
const nowMs = opts.now();
|
|
353
|
-
if (!shouldCheckForUpdate(opts.cache.read(), nowMs)) return;
|
|
354
|
-
// Stamp the check up front so a failed/offline check still backs off a day.
|
|
355
|
-
opts.cache.write(nowMs);
|
|
356
|
-
try {
|
|
357
|
-
const res = await opts.fetchImpl(opts.url ?? LATEST_RELEASE_URL, {
|
|
358
|
-
headers: { "user-agent": "caret-opencode-plugin", accept: "application/vnd.github+json" },
|
|
359
|
-
});
|
|
360
|
-
if (!res.ok) return;
|
|
361
|
-
const latest = parseLatestRelease(await res.json());
|
|
362
|
-
if (!latest) return;
|
|
363
|
-
const body = updateToastBody(opts.currentVersion, latest);
|
|
364
|
-
if (body) showToast(client, body);
|
|
365
|
-
} catch {
|
|
366
|
-
// best-effort — an update nudge must never disrupt the session.
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Where the planning steer sends the plan agent's plan file: `[opencode] plans_dir`
|
|
371
|
-
* from caret's config.toml (a leading `~` expanded), else OpenCode's data-dir
|
|
372
|
-
* `plans/`, which OpenCode's plan agent may edit. The config path mirrors
|
|
373
|
-
* src/config/paths.ts configFile() by hand, since the plugin cannot import src/. */
|
|
374
|
-
export function resolvePlansDir(opts: {
|
|
375
|
-
env: Record<string, string | undefined>;
|
|
376
|
-
home: string;
|
|
377
|
-
readFile: (path: string) => string;
|
|
378
|
-
}): string {
|
|
379
|
-
const configFile =
|
|
380
|
-
opts.env.CARET_CONFIG_FILE ||
|
|
381
|
-
`${opts.env.XDG_CONFIG_HOME || `${opts.home}/.config`}/caret/config.toml`;
|
|
382
|
-
try {
|
|
383
|
-
const config = Bun.TOML.parse(opts.readFile(configFile)) as {
|
|
384
|
-
opencode?: { plans_dir?: unknown };
|
|
385
|
-
};
|
|
386
|
-
const dir = config.opencode?.plans_dir;
|
|
387
|
-
if (typeof dir === "string" && dir) return dir.replace(/^~(?=\/|$)/, opts.home);
|
|
388
|
-
} catch {
|
|
389
|
-
// An absent or malformed config.toml leaves the default.
|
|
390
|
-
}
|
|
391
|
-
return `${opts.env.XDG_DATA_HOME || `${opts.home}/.local/share`}/opencode/plans`;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** The planning-prompt steer appended to the system array so the Plan agent
|
|
395
|
-
* submits its plan to caret instead of calling the native plan_exit. */
|
|
396
|
-
export function planningSteer(plansDir: string): string {
|
|
397
|
-
return [
|
|
398
|
-
"## Plan review (caret)",
|
|
399
|
-
"",
|
|
400
|
-
`When you have a plan ready for the user, do NOT call plan_exit. Instead write the plan as markdown to a file in \`${plansDir}/\` (a directory you may write to), for example \`${plansDir}/<short-name>.md\`, and call the \`${REVIEW_TOOL}\` tool with that file as the \`path\` argument.`,
|
|
401
|
-
"It opens caret's visual review UI in the browser; the user approves or requests changes. A change request comes back as the tool result: re-read the file, revise it with targeted edits rather than rewriting it, and call the tool again with the same `path` until it is approved. An approved plan is already saved in that file.",
|
|
402
|
-
PLAN_TITLE_INSTRUCTION,
|
|
403
|
-
].join("\n");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
101
|
// --- config-hook mutation (subagent-bypass mitigation) -------------------------
|
|
407
102
|
|
|
408
103
|
type LooseAgent = { mode?: string; permission?: unknown } & Record<string, unknown>;
|
|
@@ -452,26 +147,6 @@ function ensurePermission(agent: LooseAgent): Record<string, unknown> {
|
|
|
452
147
|
return agent.permission as Record<string, unknown>;
|
|
453
148
|
}
|
|
454
149
|
|
|
455
|
-
/** Warms the caret daemon ahead of a review. Injected so the chat.message hook is
|
|
456
|
-
* unit-testable without spawning a process. */
|
|
457
|
-
export type WarmRunner = (bin: string) => void;
|
|
458
|
-
|
|
459
|
-
/** Production warm: `caret prewarm`, detached and unref'd so it never holds up the
|
|
460
|
-
* turn. Output is discarded — the child logs to caret.log. CARET_AGENT rides along
|
|
461
|
-
* because this spawn is what stands the daemon up and the daemon picks its adapter
|
|
462
|
-
* from that env; the 'error' handler is mandatory because spawn emits 'error'
|
|
463
|
-
* ASYNCHRONOUSLY (ENOENT on a bad bin), where the hook's synchronous try/catch
|
|
464
|
-
* cannot see it and an unhandled event would take OpenCode's whole process down. */
|
|
465
|
-
const nodeWarmRunner: WarmRunner = (bin) => {
|
|
466
|
-
const child = spawn(bin, ["prewarm"], {
|
|
467
|
-
stdio: "ignore",
|
|
468
|
-
detached: true,
|
|
469
|
-
env: { ...process.env, CARET_AGENT: "opencode" },
|
|
470
|
-
});
|
|
471
|
-
child.on("error", () => {});
|
|
472
|
-
child.unref();
|
|
473
|
-
};
|
|
474
|
-
|
|
475
150
|
// ---------------------------------------------------------------------------
|
|
476
151
|
// The plugin
|
|
477
152
|
// ---------------------------------------------------------------------------
|
|
@@ -485,13 +160,11 @@ export function createCaretPlugin(
|
|
|
485
160
|
bin?: string;
|
|
486
161
|
run?: SpawnRunner;
|
|
487
162
|
warm?: WarmRunner;
|
|
488
|
-
checkUpdate?: (
|
|
163
|
+
checkUpdate?: (show: ToastSink) => void;
|
|
489
164
|
plansDir?: string;
|
|
490
165
|
} = {},
|
|
491
166
|
): Plugin {
|
|
492
|
-
const bin =
|
|
493
|
-
opts.bin ??
|
|
494
|
-
resolveCaretBin({ env: process.env, marker: CARET_BIN, importMetaUrl: import.meta.url });
|
|
167
|
+
const bin = opts.bin ?? resolveCaretBin({ env: process.env, importMetaUrl: import.meta.url });
|
|
495
168
|
const run = opts.run ?? nodeSpawnRunner;
|
|
496
169
|
const warm = opts.warm ?? nodeWarmRunner;
|
|
497
170
|
const plansDir =
|
|
@@ -508,7 +181,7 @@ export function createCaretPlugin(
|
|
|
508
181
|
const client = (input as { client?: CaretClient }).client;
|
|
509
182
|
// Startup update nudge (EXC-794), fire-and-forget. Only the production default
|
|
510
183
|
// export wires this.
|
|
511
|
-
if (opts.checkUpdate) opts.checkUpdate(client);
|
|
184
|
+
if (opts.checkUpdate) opts.checkUpdate((body) => showToast(client, body));
|
|
512
185
|
// The agent driving each session, recorded by chat.message and read by
|
|
513
186
|
// system.transform, which carries no agent of its own. Why a map is the only
|
|
514
187
|
// route: `doc/agents/opencode-integration.md` § Why gating the steer needs a
|
|
@@ -528,10 +201,12 @@ export function createCaretPlugin(
|
|
|
528
201
|
// may call the tool: the plan agent is the one whose turn reliably ends in a review,
|
|
529
202
|
// and warming on every build message would spawn a process on the session's busiest
|
|
530
203
|
// traffic to save ~0.4s in the rare case.
|
|
531
|
-
"chat.message": async (input) => {
|
|
204
|
+
"chat.message": async (input, output) => {
|
|
205
|
+
// A client may omit `input.agent`; OpenCode resolves the default onto the message.
|
|
206
|
+
const agent = input.agent ?? output.message.agent;
|
|
532
207
|
// A message whose agent is unknown must not clobber the recorded one.
|
|
533
|
-
if (input.sessionID &&
|
|
534
|
-
if (!isPlanningAgent(
|
|
208
|
+
if (input.sessionID && agent) sessionAgents.set(input.sessionID, agent);
|
|
209
|
+
if (!isPlanningAgent(agent)) return;
|
|
535
210
|
try {
|
|
536
211
|
warm(bin);
|
|
537
212
|
} catch {
|
|
@@ -557,82 +232,48 @@ export function createCaretPlugin(
|
|
|
557
232
|
},
|
|
558
233
|
tool: {
|
|
559
234
|
[REVIEW_TOOL]: tool({
|
|
560
|
-
description:
|
|
561
|
-
"Submit the current plan to caret for human review in a local browser UI. For plans only: caret presents what it receives as a plan, so do not use it for other documents or questions. Pass exactly one of `path` (preferred: a markdown file you write once and revise with edits) or `plan` (the plan inline). Blocks until the user approves or requests changes. On a change request, follow the result's instructions and call this tool again. Do not implement the plan until a call returns an approval.",
|
|
235
|
+
description: REVIEW_TOOL_DESCRIPTION,
|
|
562
236
|
args: {
|
|
563
|
-
plan: tool.schema
|
|
564
|
-
|
|
565
|
-
.optional()
|
|
566
|
-
.describe(
|
|
567
|
-
"The complete plan, as markdown, to present for human review. The inline alternative to `path`.",
|
|
568
|
-
),
|
|
569
|
-
path: tool.schema
|
|
570
|
-
.string()
|
|
571
|
-
.optional()
|
|
572
|
-
.describe(
|
|
573
|
-
`Preferred. A markdown (.md) file holding the complete plan, absolute or relative to the session directory. (OpenCode's plan agent writes its plan files in ${plansDir}/.)`,
|
|
574
|
-
),
|
|
237
|
+
plan: tool.schema.string().optional().describe(PLAN_ARG_DESCRIPTION),
|
|
238
|
+
path: tool.schema.string().optional().describe(pathArgDescription(plansDir)),
|
|
575
239
|
},
|
|
576
240
|
async execute(args, context) {
|
|
577
|
-
if (await isSubagentSession(client, context.sessionID)) {
|
|
578
|
-
return `${REVIEW_TOOL} is available to primary agents only; this call came from a subagent session. Continue without caret review, or hand the plan back to the primary agent to submit.`;
|
|
579
|
-
}
|
|
580
|
-
const source = resolvePlanSource(args, context.directory, readRegularFile);
|
|
581
|
-
if ("error" in source) return source.error;
|
|
582
|
-
if (source.planFilePath) {
|
|
583
|
-
// caret rewrites this file, and the model chose the path, so OpenCode's own
|
|
584
|
-
// edit permission must allow it.
|
|
585
|
-
try {
|
|
586
|
-
await context.ask({
|
|
587
|
-
permission: "edit",
|
|
588
|
-
patterns: [relative(context.worktree, source.planFilePath)],
|
|
589
|
-
always: ["*"],
|
|
590
|
-
metadata: { filepath: source.planFilePath },
|
|
591
|
-
});
|
|
592
|
-
} catch {
|
|
593
|
-
return `caret: ${REVIEW_TOOL} was not permitted to edit ${source.planFilePath}, which a path review rewrites. Write the plan in \`${plansDir}/\` and pass that file as \`path\`, or pass the plan inline as \`plan\`.`;
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
const envelope = buildEnvelope(source.plan, {
|
|
597
|
-
sessionID: context.sessionID,
|
|
598
|
-
directory: context.directory,
|
|
599
|
-
planFilePath: source.planFilePath,
|
|
600
|
-
});
|
|
601
241
|
let linkShown = false;
|
|
602
|
-
const decision = await
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
242
|
+
const { text, decision } = await runPlanReview(
|
|
243
|
+
args,
|
|
244
|
+
{
|
|
245
|
+
sessionID: context.sessionID,
|
|
246
|
+
directory: context.directory,
|
|
247
|
+
isSubagent: () => isSubagentSession(client, context.sessionID),
|
|
248
|
+
canEdit: async (planFilePath) => {
|
|
249
|
+
try {
|
|
250
|
+
await context.ask({
|
|
251
|
+
permission: "edit",
|
|
252
|
+
patterns: [relative(context.worktree, planFilePath)],
|
|
253
|
+
always: ["*"],
|
|
254
|
+
metadata: { filepath: planFilePath },
|
|
255
|
+
});
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
signal: context.abort,
|
|
262
|
+
onUrl: (url) => {
|
|
263
|
+
linkShown = true;
|
|
264
|
+
showToast(client, reviewLinkToast(url));
|
|
265
|
+
},
|
|
615
266
|
},
|
|
616
|
-
|
|
267
|
+
{ bin, run, plansDir },
|
|
268
|
+
);
|
|
617
269
|
// Supersede the pending review-link toast with a brief decision toast —
|
|
618
270
|
// the surface is single-slot with no hide API (EXC-691).
|
|
619
|
-
if (linkShown) {
|
|
620
|
-
showToast(
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
? {
|
|
624
|
-
message: "caret: plan approved",
|
|
625
|
-
variant: "success",
|
|
626
|
-
duration: DECISION_TOAST_MS,
|
|
627
|
-
}
|
|
628
|
-
: {
|
|
629
|
-
message: "caret: changes requested",
|
|
630
|
-
variant: "info",
|
|
631
|
-
duration: DECISION_TOAST_MS,
|
|
632
|
-
},
|
|
633
|
-
);
|
|
271
|
+
if (linkShown && context.abort.aborted) {
|
|
272
|
+
showToast(client, decisionToast("cancelled"));
|
|
273
|
+
} else if (linkShown && decision) {
|
|
274
|
+
showToast(client, decisionToast(decision.behavior));
|
|
634
275
|
}
|
|
635
|
-
return
|
|
276
|
+
return text;
|
|
636
277
|
},
|
|
637
278
|
}),
|
|
638
279
|
},
|
|
@@ -641,21 +282,7 @@ export function createCaretPlugin(
|
|
|
641
282
|
};
|
|
642
283
|
}
|
|
643
284
|
|
|
644
|
-
/**
|
|
645
|
-
* update checker. */
|
|
646
|
-
const CaretPlugin: Plugin = createCaretPlugin({
|
|
647
|
-
checkUpdate: (client) => {
|
|
648
|
-
void realUpdateChecker(client, {
|
|
649
|
-
currentVersion: resolveCaretVersion({
|
|
650
|
-
marker: CARET_PLUGIN_VERSION,
|
|
651
|
-
importMetaUrl: import.meta.url,
|
|
652
|
-
readFile: (p) => readFileSync(p, "utf-8"),
|
|
653
|
-
}),
|
|
654
|
-
env: process.env,
|
|
655
|
-
fetchImpl: fetch,
|
|
656
|
-
now: () => Date.now(),
|
|
657
|
-
cache: fileUpdateCache(updateCheckCachePath(process.env, homedir())),
|
|
658
|
-
});
|
|
659
|
-
},
|
|
660
|
-
});
|
|
285
|
+
/** caret's OpenCode v1 plugin, which index.ts's lazy `server` delegates to, wired to the
|
|
286
|
+
* production update checker. */
|
|
287
|
+
const CaretPlugin: Plugin = createCaretPlugin({ checkUpdate: productionUpdateCheck });
|
|
661
288
|
export default CaretPlugin;
|