@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.
Files changed (100) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +11 -4
  3. package/bin/caret-launcher +78 -25
  4. package/commands/doctor.md +2 -1
  5. package/dist/cli.js +2253 -904
  6. package/opencode/caret.core.ts +458 -0
  7. package/opencode/caret.plugin.ts +83 -456
  8. package/opencode/caret.plugin.v2.ts +243 -0
  9. package/opencode/caret.tui.ts +75 -0
  10. package/opencode/commands/doctor.md +2 -1
  11. package/opencode/index.ts +6 -5
  12. package/opencode/permission.ts +102 -0
  13. package/package.json +2 -1
  14. package/ui/dist/assets/{angular-html-BECXXPyr.js → angular-html-BVXNFElL.js} +1 -1
  15. package/ui/dist/assets/{angular-ts-CJKAKY9F.js → angular-ts-BBdNNVOV.js} +1 -1
  16. package/ui/dist/assets/{apl-CCcfDDqZ.js → apl-DN-9LSnm.js} +1 -1
  17. package/ui/dist/assets/{astro-DKCnAnVR.js → astro-DuMkehjz.js} +1 -1
  18. package/ui/dist/assets/{blade-DQ_JdT2N.js → blade-B_5LVqnt.js} +1 -1
  19. package/ui/dist/assets/{c-D3TwszVj.js → c-5LpWn0DJ.js} +1 -1
  20. package/ui/dist/assets/{chapel-C-2sPcCX.js → chapel-qw3-lXMw.js} +1 -1
  21. package/ui/dist/assets/{cobol-BPmo_4f_.js → cobol-BAnr3Azd.js} +1 -1
  22. package/ui/dist/assets/{coffee-DMTsNDVt.js → coffee-CGRFpAgb.js} +1 -1
  23. package/ui/dist/assets/{cpp-BJr6_Y8R.js → cpp-olfuBwwP.js} +1 -1
  24. package/ui/dist/assets/{crystal-pIWESUkD.js → crystal-qe9b46tp.js} +1 -1
  25. package/ui/dist/assets/{css-DRd9Q4Li.js → css-DkXCh44l.js} +1 -1
  26. package/ui/dist/assets/{dist-aKlRxpD_.js → dist-B1RcczPQ.js} +1 -1
  27. package/ui/dist/assets/{dist-BGfsV7n2.js → dist-BnIbJslb.js} +1 -1
  28. package/ui/dist/assets/{dist-BJ7SLVNC.js → dist-BnbtGh0F.js} +1 -1
  29. package/ui/dist/assets/{dist-BFQBMwmz.js → dist-CFbPxPI-.js} +1 -1
  30. package/ui/dist/assets/{dist-DoJTqy3z.js → dist-CHZr2-kE.js} +1 -1
  31. package/ui/dist/assets/{dist-BNr0c85A.js → dist-CMOjVm1q.js} +1 -1
  32. package/ui/dist/assets/{dist-C6t67BjE.js → dist-CfHYS987.js} +1 -1
  33. package/ui/dist/assets/{dist-CU5aMlNo.js → dist-D8lZm-8I.js} +1 -1
  34. package/ui/dist/assets/{dist-CD25BkNS.js → dist-KWgwFCxG.js} +1 -1
  35. package/ui/dist/assets/{dist-RKKeH1zs.js → dist-iPRbc51X.js} +1 -1
  36. package/ui/dist/assets/{edge-DeNk4F2G.js → edge-hytDa3H3.js} +1 -1
  37. package/ui/dist/assets/{elixir-DHc-DOO8.js → elixir-DnjerbFh.js} +1 -1
  38. package/ui/dist/assets/{elm-CJRqEcvz.js → elm-Cy60APyX.js} +1 -1
  39. package/ui/dist/assets/{erb-BEWGTHDA.js → erb-Rclm0zaT.js} +1 -1
  40. package/ui/dist/assets/{git-rebase-DzlcWhg9.js → git-rebase-Be7W4GuM.js} +1 -1
  41. package/ui/dist/assets/{glimmer-js-B-aWCRIZ.js → glimmer-js-DAN9CyDo.js} +1 -1
  42. package/ui/dist/assets/{glimmer-ts-DcjmPyQn.js → glimmer-ts-Bd4Ltw2M.js} +1 -1
  43. package/ui/dist/assets/{glsl-C9FOT-Uv.js → glsl-DIrrPSk8.js} +1 -1
  44. package/ui/dist/assets/{graphql-BgKSoRp0.js → graphql-CBXqF557.js} +1 -1
  45. package/ui/dist/assets/{hack-BOedpUUE.js → hack-D_K_3nhB.js} +1 -1
  46. package/ui/dist/assets/{haml-Cpngw1vd.js → haml-g7mpgDGW.js} +1 -1
  47. package/ui/dist/assets/{handlebars-CpW1IyBg.js → handlebars-DRWwCyqd.js} +1 -1
  48. package/ui/dist/assets/{html-CV142D8s.js → html-Byvm88Y2.js} +1 -1
  49. package/ui/dist/assets/{html-derivative-Df7h1Nuw.js → html-derivative-DpLoxBsM.js} +1 -1
  50. package/ui/dist/assets/{http-Cneddv9N.js → http-DGq_mf9b.js} +1 -1
  51. package/ui/dist/assets/{hurl-D6TS55N6.js → hurl-qkfW8ihO.js} +1 -1
  52. package/ui/dist/assets/{index-Ci9yCNtV.js → index-BwtwrzNC.js} +4 -4
  53. package/ui/dist/assets/{java-BicD3zQH.js → java-BHSPy0gM.js} +1 -1
  54. package/ui/dist/assets/{javascript-l6vKHWBV.js → javascript-BGu3ZEma.js} +1 -1
  55. package/ui/dist/assets/{jinja-BJF2iot2.js → jinja-BkUiOuUL.js} +1 -1
  56. package/ui/dist/assets/{jison-DiXPFJAT.js → jison-DQ_FQMYl.js} +1 -1
  57. package/ui/dist/assets/{json-BDRuI4UV.js → json-BHyU93c5.js} +1 -1
  58. package/ui/dist/assets/{jsx-BvfdkIOy.js → jsx-DhxI-Xjj.js} +1 -1
  59. package/ui/dist/assets/{julia-bXAhreI5.js → julia-BpY5TdkA.js} +1 -1
  60. package/ui/dist/assets/{just-BOxCfXDg.js → just-Dy9X30SL.js} +1 -1
  61. package/ui/dist/assets/{latex-B9OiTBLF.js → latex-DnqjVTTq.js} +1 -1
  62. package/ui/dist/assets/{liquid-DuLKDeWE.js → liquid-DSLF1wIz.js} +1 -1
  63. package/ui/dist/assets/{lua-Dbh9FQm_.js → lua-CWQ1tn9q.js} +1 -1
  64. package/ui/dist/assets/{marko--tjEBgDM.js → marko-CVOaD8Lr.js} +1 -1
  65. package/ui/dist/assets/{mdc-ByNJpl3m.js → mdc-CorrsEtK.js} +1 -1
  66. package/ui/dist/assets/{nginx-D4wsmFK9.js → nginx--n2A1zuK.js} +1 -1
  67. package/ui/dist/assets/{nim-Cy8RxisP.js → nim-eb5p3yB7.js} +1 -1
  68. package/ui/dist/assets/{org-CAm7o61d.js → org-Cnlel0O3.js} +1 -1
  69. package/ui/dist/assets/{perl-BAvaFGKe.js → perl-DDWGMuBV.js} +1 -1
  70. package/ui/dist/assets/{php-DpAf3w7u.js → php-Bz2SRtKk.js} +1 -1
  71. package/ui/dist/assets/{pug-Dm7otyJU.js → pug-cGPMemK2.js} +1 -1
  72. package/ui/dist/assets/{qml-D4rWQQKC.js → qml-B4Hbmvdi.js} +1 -1
  73. package/ui/dist/assets/{r-DMKUX8qf.js → r-BuuNGonz.js} +1 -1
  74. package/ui/dist/assets/{razor-BIUJkuf_.js → razor-CHpWr94T.js} +1 -1
  75. package/ui/dist/assets/{regexp-D8W_pVHj.js → regexp-9s1znDWq.js} +1 -1
  76. package/ui/dist/assets/{rst-aFr0DP5H.js → rst-BBsXWDuP.js} +1 -1
  77. package/ui/dist/assets/{ruby-BzH6kUKr.js → ruby-pQqViN30.js} +1 -1
  78. package/ui/dist/assets/{sas-CDk9DyCV.js → sas-C4SSIJLx.js} +1 -1
  79. package/ui/dist/assets/{scss-DABbvU5W.js → scss-D8e130Cq.js} +1 -1
  80. package/ui/dist/assets/{shellscript-B0ObP3K-.js → shellscript-CHUzbqv1.js} +1 -1
  81. package/ui/dist/assets/{shellsession-2sv3Jq0j.js → shellsession-BulTL_bH.js} +1 -1
  82. package/ui/dist/assets/{soy-irSRV4oq.js → soy-zGj4V7Ec.js} +1 -1
  83. package/ui/dist/assets/{sql-DJCHoKvP.js → sql-CnJYFgwb.js} +1 -1
  84. package/ui/dist/assets/{stata-a8YnzbIc.js → stata-BXDojqs4.js} +1 -1
  85. package/ui/dist/assets/{surrealql-DdXPOg8s.js → surrealql-3hRFCpnH.js} +1 -1
  86. package/ui/dist/assets/{svelte-ClAKUEZR.js → svelte-C8TzlKVd.js} +1 -1
  87. package/ui/dist/assets/{templ-NUCvvUBA.js → templ-D5KJNb8h.js} +1 -1
  88. package/ui/dist/assets/{tex-DwZeSAfC.js → tex-szX76TU8.js} +1 -1
  89. package/ui/dist/assets/{ts-tags-CFJEHYY_.js → ts-tags-H1v1spsZ.js} +1 -1
  90. package/ui/dist/assets/{tsx-B934yX2T.js → tsx-CoyAoKjY.js} +1 -1
  91. package/ui/dist/assets/{twig-NFkkOj3w.js → twig-pqXRFgM9.js} +1 -1
  92. package/ui/dist/assets/{typescript-7bmMC_xU.js → typescript-CNl_4SRH.js} +1 -1
  93. package/ui/dist/assets/{typst-BQEjp9tn.js → typst-Dr_K1Jqf.js} +1 -1
  94. package/ui/dist/assets/{vue-JqD9ieOF.js → vue-Wl1CPRGX.js} +1 -1
  95. package/ui/dist/assets/{vue-html-CT4XQRX1.js → vue-html-F5NdFsoh.js} +1 -1
  96. package/ui/dist/assets/{vue-vine-DIR27Dmn.js → vue-vine-YWSKUX3v.js} +1 -1
  97. package/ui/dist/assets/{xml-DhsMbw-1.js → xml-crSJzq2-.js} +1 -1
  98. package/ui/dist/assets/{xsl-BvqsVUg9.js → xsl-D-YjJFxJ.js} +1 -1
  99. package/ui/dist/assets/{yaml-C8eWjK_Q.js → yaml-BZWjhwN4.js} +1 -1
  100. package/ui/dist/index.html +1 -1
@@ -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; OpenCode loads it when the package
19
- // is in the user's `plugin` array, and it resolves its own binary and version at
20
- // runtime from that package. (The legacy file-deploy path substitutes the two
21
- // __CARET_*__ markers instead.) It stays self-contained: its only imports are node
22
- // builtins, its sibling review-bridge.ts, and @opencode-ai/plugin (resolved by OpenCode
23
- // at runtime). Which ctx/tool/config shapes are live-verified and which are not:
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 { spawn } from "node:child_process";
27
- import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
28
+ import { readFileSync } from "node:fs";
28
29
  import { homedir } from "node:os";
29
- import { dirname, relative, resolve } from "node:path";
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
- buildEnvelope,
36
- decisionText,
37
- nodeSpawnRunner,
38
- PLAN_TITLE_INSTRUCTION,
39
- runReviewViaCaret,
40
- type SpawnRunner,
41
- } from "./review-bridge.ts";
42
-
43
- /** Install-time markers. The legacy file-deploy path substituted these with the
44
- * resolved caret version and binary path; the array install leaves them as
45
- * placeholders, so the resolvers below fall back to the package that ships this
46
- * file. */
47
- export const CARET_PLUGIN_VERSION = "__CARET_VERSION__";
48
- const CARET_BIN = "__CARET_BIN__";
49
-
50
- /** The caret binary the review tool spawns. Env override wins; then a substituted
51
- * marker (an absolute path, from the legacy file-deploy path); else the binary that
52
- * ships beside this module in the npm package (the array install). */
53
- export function resolveCaretBin(opts: {
54
- env: Record<string, string | undefined>;
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 ToastBody = {
114
- title?: string;
115
- message: string;
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
- /** How long the pending review-link toast stays up. Long enough to outlast a
132
- * review; on a decision we supersede it with the short toast below, so this
133
- * ceiling only bites if the review process dies without ever deciding. */
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
- try {
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?: (client: ToastClient) => void;
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 && input.agent) sessionAgents.set(input.sessionID, input.agent);
534
- if (!isPlanningAgent(input.agent)) return;
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
- .string()
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 runReviewViaCaret(envelope, {
603
- command: [bin, "review"],
604
- agent: "opencode",
605
- run,
606
- // Show the review URL as a toast while the plan is pending.
607
- onUrl: (url) => {
608
- linkShown = true;
609
- showToast(client, {
610
- title: REVIEW_TOAST_TITLE,
611
- message: url,
612
- variant: "info",
613
- duration: REVIEW_TOAST_MS,
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
- client,
622
- decision.behavior === "allow"
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 decisionText(decision, REVIEW_TOOL, source.planFilePath);
276
+ return text;
636
277
  },
637
278
  }),
638
279
  },
@@ -641,21 +282,7 @@ export function createCaretPlugin(
641
282
  };
642
283
  }
643
284
 
644
- /** The plugin OpenCode loads (bare default-export function), wiring the production
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;