@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
@@ -0,0 +1,458 @@
1
+ // caret's host-neutral OpenCode core: the review tool's name, model-facing text and
2
+ // runPlanReview, the planning steer, the caret binary/version/plans-dir resolution, the
3
+ // warm runner, the toast bodies and tool-call metadata keys, and the update check.
4
+ // caret.plugin.ts (v1), caret.plugin.v2.ts and caret.tui.ts are thin host adapters over
5
+ // it. Its imports are node builtins and review-bridge.ts only, so loading it never
6
+ // evaluates either host's SDK; test/structure/dependency-placement.test.ts holds it there.
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import {
15
+ buildEnvelope,
16
+ type CaretDecision,
17
+ decisionText,
18
+ PLAN_TITLE_INSTRUCTION,
19
+ runReviewViaCaret,
20
+ type SpawnRunner,
21
+ } from "./review-bridge.ts";
22
+
23
+ /** The caret binary the review tool spawns. Env override wins; else the binary that
24
+ * ships beside this module in the npm package. */
25
+ export function resolveCaretBin(opts: {
26
+ env: Record<string, string | undefined>;
27
+ importMetaUrl: string;
28
+ }): string {
29
+ const override = opts.env.CARET_OPENCODE_BIN?.trim();
30
+ if (override) return override;
31
+ return fileURLToPath(new URL("../bin/caret", opts.importMetaUrl));
32
+ }
33
+
34
+ /** The plugin's own caret version, for the update check, read from the package.json
35
+ * shipped beside this module. "unknown" when it is unreadable — deliberately UNPARSEABLE
36
+ * so `isNewer` compares false and a broken read stays silent ("0.0.0" would parse and
37
+ * nag "update available (you have 0.0.0)" on every start). */
38
+ export function resolveCaretVersion(opts: {
39
+ importMetaUrl: string;
40
+ readFile: (path: string) => string;
41
+ }): string {
42
+ try {
43
+ const raw = opts.readFile(fileURLToPath(new URL("../package.json", opts.importMetaUrl)));
44
+ const v = (JSON.parse(raw) as { version?: unknown }).version;
45
+ return typeof v === "string" ? v : "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ /** The plan-review tool the Plan agent calls. */
52
+ export const REVIEW_TOOL = "caret_review_plan";
53
+
54
+ /** OpenCode's built-in primary planning agent — the one agent caret steers toward
55
+ * the review tool and warms the daemon for. */
56
+ export const PLANNING_AGENTS = ["plan"] as const;
57
+
58
+ /** True for the planning agent(s) caret treats specially — the planning steer and
59
+ * the daemon warm-up both fire only for them. Not a permission check: the review
60
+ * tool itself is open to every primary agent. */
61
+ export function isPlanningAgent(agent: string | undefined): boolean {
62
+ return agent !== undefined && (PLANNING_AGENTS as readonly string[]).includes(agent);
63
+ }
64
+
65
+ /** The label caret puts in the review toast's TITLE. The review URL goes in the
66
+ * toast MESSAGE alone, not concatenated after this — OpenCode's toast word-wraps,
67
+ * and a URL sharing a line with this prefix breaks across the wrap and stops being
68
+ * terminal-clickable (EXC-691). caret owns this toast surface because OpenCode
69
+ * renders `caret_review_plan` as a generic tool whose running state never shows the
70
+ * tool's `metadata` title. */
71
+ const REVIEW_TOAST_TITLE = "caret: review this plan";
72
+
73
+ /** A toast's content — structurally both v1's `showToast` body and v2's TUI
74
+ * `ToastOptions`, which `caret.tui.ts` passes it to unchanged. */
75
+ export type ToastBody = {
76
+ title?: string;
77
+ message: string;
78
+ variant: "info" | "success" | "warning" | "error";
79
+ duration?: number;
80
+ };
81
+
82
+ /** Where a host shows a toast: v2's `ctx.ui.toast.show`, or v1's client as its adapter
83
+ * wraps it. A sink may throw or reject, so call it through `toastBestEffort`. */
84
+ export type ToastSink = (body: ToastBody) => unknown;
85
+
86
+ /** Show a toast best-effort: a toast must never crash or delay the review or plugin load,
87
+ * so a sync throw or async rejection is swallowed. */
88
+ export function toastBestEffort(show: ToastSink, body: ToastBody): void {
89
+ try {
90
+ Promise.resolve(show(body)).catch(() => {});
91
+ } catch {}
92
+ }
93
+
94
+ /** How long the pending review-link toast stays up. Long enough to outlast a
95
+ * review; on a decision we supersede it with the short toast below, so this
96
+ * ceiling only bites if the review process dies without ever deciding. */
97
+ const REVIEW_TOAST_MS = 10 * 60_000;
98
+ /** The brief decision toast that supersedes (and thereby clears) the review-link
99
+ * toast, since OpenCode's single-slot toast surface has no hide API. */
100
+ const DECISION_TOAST_MS = 4_000;
101
+
102
+ /** Tool-call metadata keys that carry the review URL and decision from the v2 server
103
+ * half to its TUI half, whose tool events name the call but not the tool. */
104
+ export const CARET_URL_KEY = "caretUrl";
105
+ export const CARET_DECISION_KEY = "caretDecision";
106
+
107
+ export function reviewLinkToast(url: string): ToastBody {
108
+ return { title: REVIEW_TOAST_TITLE, message: url, variant: "info", duration: REVIEW_TOAST_MS };
109
+ }
110
+
111
+ export type ReviewOutcome = CaretDecision["behavior"] | "cancelled";
112
+
113
+ /** The toast that supersedes the review link. */
114
+ export function decisionToast(outcome: ReviewOutcome): ToastBody {
115
+ switch (outcome) {
116
+ case "allow":
117
+ return { message: "caret: plan approved", variant: "success", duration: DECISION_TOAST_MS };
118
+ case "deny":
119
+ return { message: "caret: changes requested", variant: "info", duration: DECISION_TOAST_MS };
120
+ case "cancelled":
121
+ return { message: "caret: review cancelled", variant: "info", duration: DECISION_TOAST_MS };
122
+ }
123
+ }
124
+
125
+ type PlanSource = { plan: string; planFilePath?: string };
126
+
127
+ /** The plan the review tool submits, from exactly one of its `plan` / `path` args (an
128
+ * empty string counts as absent). A `path` resolves against the session directory and
129
+ * must be a readable `.md` file — mirroring the core's `isPlanFile`
130
+ * (src/plan/canonical-file.ts), repeated here because this module cannot import src/.
131
+ * `readFile` returns the text, or undefined when absPath is not a readable regular
132
+ * file. */
133
+ export function resolvePlanSource(
134
+ args: { plan?: string; path?: string },
135
+ directory: string,
136
+ readFile: (absPath: string) => string | undefined,
137
+ ): PlanSource | { error: string } {
138
+ const plan = args.plan || undefined;
139
+ const path = args.path || undefined;
140
+ const notExactlyOne = {
141
+ error: `caret: ${REVIEW_TOOL} takes exactly one of \`plan\` or \`path\`. Pass the plan file as \`path\`, or the plan inline as \`plan\`.`,
142
+ };
143
+ if (path === undefined) return plan === undefined ? notExactlyOne : { plan };
144
+ if (plan !== undefined) return notExactlyOne;
145
+ const planFilePath = resolve(directory, path);
146
+ if (!planFilePath.endsWith(".md")) {
147
+ return {
148
+ 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\`.`,
149
+ };
150
+ }
151
+ const text = readFile(planFilePath);
152
+ if (text === undefined) {
153
+ return {
154
+ error: `caret: ${REVIEW_TOOL} could not read ${planFilePath} as a regular file. Write the plan there first, or pass it inline as \`plan\`.`,
155
+ };
156
+ }
157
+ return { plan: text, planFilePath };
158
+ }
159
+
160
+ function readRegularFile(absPath: string): string | undefined {
161
+ try {
162
+ return statSync(absPath).isFile() ? readFileSync(absPath, "utf-8") : undefined;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ // --- version-check toast (EXC-794) -----------------------------------------
169
+
170
+ /** caret's latest-release endpoint. Unauthenticated GitHub API — throttled to at
171
+ * most once a day (see UPDATE_CHECK_INTERVAL_MS), so it never approaches the
172
+ * 60 req/hr/IP limit. */
173
+ const LATEST_RELEASE_URL = "https://api.github.com/repos/macintacos/caret/releases/latest";
174
+ /** How long the update nudge stays up — long enough to read the link. */
175
+ const UPDATE_TOAST_MS = 5_000;
176
+ /** Minimum gap between update checks. The nudge is a convenience, not a security
177
+ * fix, so checking once a day is plenty — and it keeps the network hit off every
178
+ * OpenCode start. */
179
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
180
+
181
+ /** Semver triple `[major, minor, patch]`, or null when `v` is not `X.Y.Z` (an
182
+ * optional leading `v` is stripped; trailing prerelease/build metadata ignored). */
183
+ function parseVersionTriple(v: string): [number, number, number] | null {
184
+ const m = v
185
+ .trim()
186
+ .replace(/^v/, "")
187
+ .match(/^(\d+)\.(\d+)\.(\d+)/);
188
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
189
+ }
190
+
191
+ /** True when `latest` is a strictly higher semver than `current`. Inline (no semver
192
+ * dep) so the deployed plugin stays self-contained; an unparseable version on
193
+ * either side compares false, so the check never nags on something it can't read. */
194
+ export function isNewer(latest: string, current: string): boolean {
195
+ const a = parseVersionTriple(latest);
196
+ const b = parseVersionTriple(current);
197
+ if (!a || !b) return false;
198
+ const [a0, a1, a2] = a;
199
+ const [b0, b1, b2] = b;
200
+ if (a0 !== b0) return a0 > b0;
201
+ if (a1 !== b1) return a1 > b1;
202
+ return a2 > b2;
203
+ }
204
+
205
+ /** The version (v-stripped) and release-page URL from GitHub's /releases/latest
206
+ * JSON, or null when the shape is unusable. */
207
+ export function parseLatestRelease(json: unknown): { version: string; url: string } | null {
208
+ if (typeof json !== "object" || json === null) return null;
209
+ const o = json as { tag_name?: unknown; html_url?: unknown };
210
+ if (typeof o.tag_name !== "string") return null;
211
+ const url =
212
+ typeof o.html_url === "string"
213
+ ? o.html_url
214
+ : "https://github.com/macintacos/caret/releases/latest";
215
+ return { version: o.tag_name.replace(/^v/, ""), url };
216
+ }
217
+
218
+ /** The update-available toast body, or null when the user is already current. */
219
+ export function updateToastBody(
220
+ current: string,
221
+ latest: { version: string; url: string },
222
+ ): ToastBody | null {
223
+ if (!isNewer(latest.version, current)) return null;
224
+ return {
225
+ title: "caret update available",
226
+ message: `caret ${latest.version} is available (you have ${current}). ${latest.url}`,
227
+ variant: "info",
228
+ duration: UPDATE_TOAST_MS,
229
+ };
230
+ }
231
+
232
+ /** The slice of `fetch` the update check needs — narrow so a test can pass a plain
233
+ * stub without reconstructing `fetch`'s `preconnect` sibling. The global `fetch`
234
+ * (and Bun's) is assignable to it. */
235
+ type FetchLike = (
236
+ url: string,
237
+ init?: { headers?: Record<string, string> },
238
+ ) => Promise<{ ok: boolean; json: () => Promise<unknown> }>;
239
+
240
+ /** True when the last check is old enough (or absent) to check again. Pure so the
241
+ * 24h throttle is unit-testable without a clock or the filesystem. */
242
+ export function shouldCheckForUpdate(lastCheckMs: number | null, nowMs: number): boolean {
243
+ return lastCheckMs === null || nowMs - lastCheckMs >= UPDATE_CHECK_INTERVAL_MS;
244
+ }
245
+
246
+ /** Absolute path of the throttle file holding the last-check epoch-ms. Lives under
247
+ * caret's state dir, beside caret's other small machine-global markers (update-check.json).
248
+ * The plugin stays self-contained and cannot import src/config/paths.ts, so that
249
+ * convention is mirrored here by hand. */
250
+ export function updateCheckCachePath(
251
+ env: Record<string, string | undefined>,
252
+ home: string,
253
+ ): string {
254
+ const base = env.XDG_STATE_HOME?.trim() || `${home}/.local/state`;
255
+ return `${base}/caret/opencode-update-check`;
256
+ }
257
+
258
+ /** The throttle seam: read the last-check epoch-ms (null when never / unreadable),
259
+ * and persist a new one. Injected so realUpdateChecker's throttle is testable
260
+ * without touching disk. */
261
+ type UpdateCache = { read: () => number | null; write: (epochMs: number) => void };
262
+
263
+ /** File-backed UpdateCache. Read tolerates a missing or garbage file (→ null, so
264
+ * the check runs); write is best-effort — a failure just means we check again next
265
+ * start rather than crashing plugin load. */
266
+ function fileUpdateCache(path: string): UpdateCache {
267
+ return {
268
+ read: () => {
269
+ try {
270
+ const n = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
271
+ return Number.isFinite(n) ? n : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ },
276
+ write: (epochMs) => {
277
+ try {
278
+ mkdirSync(dirname(path), { recursive: true });
279
+ writeFileSync(path, String(epochMs));
280
+ } catch {
281
+ // best-effort — a throttle-file write must never disrupt load.
282
+ }
283
+ },
284
+ };
285
+ }
286
+
287
+ /** Best-effort startup update check: fetch caret's latest GitHub release and, if it
288
+ * is newer than this plugin's version, toast a nudge with a link. Throttled to at
289
+ * most once a day via the injected cache. Never throws and never blocks plugin load
290
+ * — the `CARET_OPENCODE_NO_UPDATE_CHECK` opt-out and every failure resolve
291
+ * silently. */
292
+ export async function realUpdateChecker(
293
+ show: ToastSink,
294
+ opts: {
295
+ currentVersion: string;
296
+ env: Record<string, string | undefined>;
297
+ fetchImpl: FetchLike;
298
+ now: () => number;
299
+ cache: UpdateCache;
300
+ url?: string;
301
+ },
302
+ ): Promise<void> {
303
+ if (opts.env.CARET_OPENCODE_NO_UPDATE_CHECK) return;
304
+ const nowMs = opts.now();
305
+ if (!shouldCheckForUpdate(opts.cache.read(), nowMs)) return;
306
+ // Stamp the check up front so a failed/offline check still backs off a day.
307
+ opts.cache.write(nowMs);
308
+ try {
309
+ const res = await opts.fetchImpl(opts.url ?? LATEST_RELEASE_URL, {
310
+ headers: { "user-agent": "caret-opencode-plugin", accept: "application/vnd.github+json" },
311
+ });
312
+ if (!res.ok) return;
313
+ const latest = parseLatestRelease(await res.json());
314
+ if (!latest) return;
315
+ const body = updateToastBody(opts.currentVersion, latest);
316
+ if (body) toastBestEffort(show, body);
317
+ } catch {
318
+ // best-effort — an update nudge must never disrupt the session.
319
+ }
320
+ }
321
+
322
+ /** Where the planning steer sends the plan agent's plan file: `[opencode] plans_dir`
323
+ * from caret's config.toml (a leading `~` expanded), else `defaultDir`, the host's
324
+ * plan-agent directory, which defaults to OpenCode v1's data-dir `plans/`
325
+ * (`$XDG_DATA_HOME` honoured). The config path mirrors
326
+ * src/config/paths.ts configFile() by hand, since the plugin cannot import src/. */
327
+ export function resolvePlansDir(opts: {
328
+ env: Record<string, string | undefined>;
329
+ home: string;
330
+ readFile: (path: string) => string;
331
+ defaultDir?: string;
332
+ }): string {
333
+ const configFile =
334
+ opts.env.CARET_CONFIG_FILE ||
335
+ `${opts.env.XDG_CONFIG_HOME || `${opts.home}/.config`}/caret/config.toml`;
336
+ try {
337
+ const config = Bun.TOML.parse(opts.readFile(configFile)) as {
338
+ opencode?: { plans_dir?: unknown };
339
+ };
340
+ const dir = config.opencode?.plans_dir;
341
+ if (typeof dir === "string" && dir) return dir.replace(/^~(?=\/|$)/, opts.home);
342
+ } catch {
343
+ // An absent or malformed config.toml leaves the default.
344
+ }
345
+ return (
346
+ opts.defaultDir ?? `${opts.env.XDG_DATA_HOME || `${opts.home}/.local/share`}/opencode/plans`
347
+ );
348
+ }
349
+
350
+ /** The planning-prompt steer appended to the plan agent's system prompt so it submits
351
+ * its plan to caret rather than ending planning any other way. Worded for both OpenCode
352
+ * v1 (which has plan_exit) and v2 (which does not). */
353
+ export function planningSteer(plansDir: string): string {
354
+ return [
355
+ "## Plan review (caret)",
356
+ "",
357
+ `When you have a plan ready for the user, submit it by calling the \`${REVIEW_TOOL}\` tool, not by ending planning any other way (for example with plan_exit). The user asks you to write the plan as markdown to a file in \`${plansDir}/\`, for example \`${plansDir}/<short-name>.md\`, and pass that file as the \`path\` argument. If you may not write that file, pass the plan inline as the \`plan\` argument instead.`,
358
+ "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. With `path`, 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.",
359
+ PLAN_TITLE_INSTRUCTION,
360
+ ].join("\n");
361
+ }
362
+
363
+ /** Warms the caret daemon ahead of a review. Injected so the chat.message hook is
364
+ * unit-testable without spawning a process. */
365
+ export type WarmRunner = (bin: string) => void;
366
+
367
+ /** Production warm: `caret prewarm`, detached and unref'd so it never holds up the
368
+ * turn. Output is discarded — the child logs to caret.log. CARET_AGENT rides along
369
+ * because this spawn is what stands the daemon up and the daemon picks its adapter
370
+ * from that env; the 'error' handler is mandatory because spawn emits 'error'
371
+ * ASYNCHRONOUSLY (ENOENT on a bad bin), where the hook's synchronous try/catch
372
+ * cannot see it and an unhandled event would take OpenCode's whole process down. */
373
+ export const nodeWarmRunner: WarmRunner = (bin) => {
374
+ const child = spawn(bin, ["prewarm"], {
375
+ stdio: "ignore",
376
+ detached: true,
377
+ env: { ...process.env, CARET_AGENT: "opencode" },
378
+ });
379
+ child.on("error", () => {});
380
+ child.unref();
381
+ };
382
+
383
+ // The review tool's model-facing text. Both hosts' registrations read these, so v1 and
384
+ // v2 stay word-for-word identical (plugin-v2.test.ts pins the refusal texts).
385
+ export const REVIEW_TOOL_DESCRIPTION =
386
+ "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.";
387
+
388
+ export const PLAN_ARG_DESCRIPTION =
389
+ "The complete plan, as markdown, to present for human review. The inline alternative to `path`.";
390
+
391
+ export function pathArgDescription(plansDir: string): string {
392
+ return `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}/.)`;
393
+ }
394
+
395
+ export const SUBAGENT_REFUSAL = `${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.`;
396
+
397
+ export function notPermittedToEdit(planFilePath: string, plansDir: string): string {
398
+ return `caret: ${REVIEW_TOOL} was not permitted to edit ${planFilePath}, which a path review rewrites. Write the plan in \`${plansDir}/\` and pass that file as \`path\`, or pass the plan inline as \`plan\`.`;
399
+ }
400
+
401
+ /** What differs per OpenCode host for one review-tool call. */
402
+ export type ReviewHost = {
403
+ sessionID: string;
404
+ /** The base a `path` resolves against. */
405
+ directory: string;
406
+ /** Fail-open: false when the session is unreadable. */
407
+ isSubagent: () => Promise<boolean>;
408
+ /** caret rewrites the file and the model chose the path, so OpenCode's own edit rules
409
+ * must allow it. false = denied, ask-only, or the check failed. */
410
+ canEdit: (planFilePath: string) => Promise<boolean>;
411
+ /** Aborting it kills the `caret review` child. */
412
+ signal?: AbortSignal;
413
+ /** Fires once with the review URL while the review is pending. */
414
+ onUrl?: (url: string) => void;
415
+ };
416
+
417
+ /** One review-tool call, host-neutral: subagent refusal, plan source, the `path` edit
418
+ * check, then `caret review`. `decision` is absent when the call was refused before
419
+ * spawning. */
420
+ export async function runPlanReview(
421
+ args: { plan?: string; path?: string },
422
+ host: ReviewHost,
423
+ deps: { bin: string; run: SpawnRunner; plansDir: string },
424
+ ): Promise<{ text: string; decision?: CaretDecision }> {
425
+ if (await host.isSubagent()) return { text: SUBAGENT_REFUSAL };
426
+ const source = resolvePlanSource(args, host.directory, readRegularFile);
427
+ if ("error" in source) return { text: source.error };
428
+ if (source.planFilePath && !(await host.canEdit(source.planFilePath))) {
429
+ return { text: notPermittedToEdit(source.planFilePath, deps.plansDir) };
430
+ }
431
+ const envelope = buildEnvelope(source.plan, {
432
+ sessionID: host.sessionID,
433
+ directory: host.directory,
434
+ planFilePath: source.planFilePath,
435
+ });
436
+ const decision = await runReviewViaCaret(envelope, {
437
+ command: [deps.bin, "review"],
438
+ agent: "opencode",
439
+ run: deps.run,
440
+ signal: host.signal,
441
+ onUrl: host.onUrl,
442
+ });
443
+ return { text: decisionText(decision, REVIEW_TOOL, source.planFilePath), decision };
444
+ }
445
+
446
+ /** The update check both hosts run, wired to production; one file stamp throttles both. */
447
+ export function productionUpdateCheck(show: ToastSink): void {
448
+ void realUpdateChecker(show, {
449
+ currentVersion: resolveCaretVersion({
450
+ importMetaUrl: import.meta.url,
451
+ readFile: (p) => readFileSync(p, "utf-8"),
452
+ }),
453
+ env: process.env,
454
+ fetchImpl: fetch,
455
+ now: () => Date.now(),
456
+ cache: fileUpdateCache(updateCheckCachePath(process.env, homedir())),
457
+ });
458
+ }