@macintacos/caret 1.8.1 → 1.9.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 (112) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +34 -7
  3. package/bin/caret-launcher +15 -10
  4. package/{opencode → bridge}/review-bridge.ts +153 -12
  5. package/commands/doctor.md +4 -0
  6. package/dist/cli.js +23980 -23370
  7. package/opencode/caret.core.ts +26 -113
  8. package/opencode/caret.plugin.ts +11 -5
  9. package/opencode/caret.plugin.v2.ts +12 -4
  10. package/opencode/caret.tui.ts +6 -18
  11. package/opencode/commands/demo.md +1 -1
  12. package/opencode/commands/doctor.md +4 -0
  13. package/package.json +18 -3
  14. package/pi/caret.ts +145 -0
  15. package/pi/demo.ts +79 -0
  16. package/pi/doctor.ts +149 -0
  17. package/pi/follow-up.ts +19 -0
  18. package/pi/gate.ts +49 -0
  19. package/pi/notice.ts +127 -0
  20. package/pi/plan.ts +182 -0
  21. package/pi/render.ts +51 -0
  22. package/pi/state.ts +74 -0
  23. package/pi/tool.ts +150 -0
  24. package/ui/dist/assets/{angular-html-BHGQgfyp.js → angular-html-DTAmLsI1.js} +1 -1
  25. package/ui/dist/assets/{angular-ts-CjJOXiTk.js → angular-ts-CnmIqVSv.js} +1 -1
  26. package/ui/dist/assets/{apl-BKCXbKuk.js → apl-BRrI8o6u.js} +1 -1
  27. package/ui/dist/assets/{astro-CbIGh4kW.js → astro-ws1SFlqZ.js} +1 -1
  28. package/ui/dist/assets/{blade-Dlz3TW50.js → blade-BAfome-F.js} +1 -1
  29. package/ui/dist/assets/{c-CNaIUpZJ.js → c-Cb8iBkqY.js} +1 -1
  30. package/ui/dist/assets/{chapel-BSTn-HqM.js → chapel-CwNxsGmt.js} +1 -1
  31. package/ui/dist/assets/{cobol-Ch_1nBvr.js → cobol-C6senUsW.js} +1 -1
  32. package/ui/dist/assets/{coffee-DlKg6sYw.js → coffee-BNmOwO3K.js} +1 -1
  33. package/ui/dist/assets/{cpp-CPx3-f9p.js → cpp-BBfuAVc8.js} +1 -1
  34. package/ui/dist/assets/{crystal-Cgvix66v.js → crystal-BIenSyu_.js} +1 -1
  35. package/ui/dist/assets/{css-CwPT7Xr2.js → css-BM7X2qQt.js} +1 -1
  36. package/ui/dist/assets/{dist-DH3vs96X.js → dist-8Qj1RsyX.js} +1 -1
  37. package/ui/dist/assets/{dist-DFQEDf6Q.js → dist-BBj5r4JZ.js} +1 -1
  38. package/ui/dist/assets/{dist-D2BIUS34.js → dist-B_l6M6p7.js} +1 -1
  39. package/ui/dist/assets/{dist-BdIbTHZ2.js → dist-Bsjq6LzO.js} +1 -1
  40. package/ui/dist/assets/{dist-BqPfD9yL.js → dist-Bxb_HUlD.js} +1 -1
  41. package/ui/dist/assets/{dist-Dze-hH7D.js → dist-C7V4XWMN.js} +1 -1
  42. package/ui/dist/assets/{dist-Cq6qfECE.js → dist-Cq8hWpOn.js} +1 -1
  43. package/ui/dist/assets/{dist-C6pK9dBg.js → dist-fytLbOcF.js} +1 -1
  44. package/ui/dist/assets/{dist-B7Ila1Oa.js → dist-zbP7SV6c.js} +1 -1
  45. package/ui/dist/assets/{dist-nsVj3Kzw.js → dist-zezPBQmO.js} +1 -1
  46. package/ui/dist/assets/{edge-C6856TSk.js → edge-BlclzlRw.js} +1 -1
  47. package/ui/dist/assets/{elixir-nUsO5D1t.js → elixir-BYumuPVp.js} +1 -1
  48. package/ui/dist/assets/{elm-BWmeidnG.js → elm-DNBykypS.js} +1 -1
  49. package/ui/dist/assets/{erb-DgYxypnH.js → erb-Cg-GLw-3.js} +1 -1
  50. package/ui/dist/assets/{git-rebase-Bu77Zji8.js → git-rebase-DcwetDCn.js} +1 -1
  51. package/ui/dist/assets/{glimmer-js-C-Er2CH_.js → glimmer-js-DwUQB02R.js} +1 -1
  52. package/ui/dist/assets/{glimmer-ts-DHJp3ZV-.js → glimmer-ts-DNxDwzad.js} +1 -1
  53. package/ui/dist/assets/{glsl-CkN2-uXa.js → glsl-CTQvGLl-.js} +1 -1
  54. package/ui/dist/assets/{graphql-DM_zS2Al.js → graphql-6N6VWnCL.js} +1 -1
  55. package/ui/dist/assets/{hack-BjOgct5l.js → hack-BIWJ2t_-.js} +1 -1
  56. package/ui/dist/assets/{haml-CY4ICINw.js → haml-CTaug-6I.js} +1 -1
  57. package/ui/dist/assets/{handlebars-DLsdnsgP.js → handlebars-EciX6Xtm.js} +1 -1
  58. package/ui/dist/assets/{html-Cw-p-zYu.js → html-5ZVMwxqg.js} +1 -1
  59. package/ui/dist/assets/{html-derivative-CDmHYwVW.js → html-derivative-Bo7PACv6.js} +1 -1
  60. package/ui/dist/assets/{http-Bqbe5jqD.js → http-D14aglwt.js} +1 -1
  61. package/ui/dist/assets/{hurl-Ca-chcCT.js → hurl-DGa-Lc1_.js} +1 -1
  62. package/ui/dist/assets/index-BocyxbG2.css +1 -0
  63. package/ui/dist/assets/{index-DLUt2ehA.js → index-Do48mTUl.js} +226 -225
  64. package/ui/dist/assets/{java-0P1dqfZ8.js → java-BUpF6706.js} +1 -1
  65. package/ui/dist/assets/{javascript-VI2-fvZG.js → javascript-CU7AY0Mj.js} +1 -1
  66. package/ui/dist/assets/{jinja-Dz73I98G.js → jinja-OREkRLMJ.js} +1 -1
  67. package/ui/dist/assets/{jison-BtT1z3MS.js → jison-Zf3gmndI.js} +1 -1
  68. package/ui/dist/assets/{json-C4tTi3F5.js → json-C8E_SzMr.js} +1 -1
  69. package/ui/dist/assets/{jsx-tTJjMA8e.js → jsx-CHMjfgSc.js} +1 -1
  70. package/ui/dist/assets/{julia-rokLSeEC.js → julia-DnO66Wng.js} +1 -1
  71. package/ui/dist/assets/{just-qeFPHb0X.js → just-BUkZWNEk.js} +1 -1
  72. package/ui/dist/assets/{latex-Beecofx6.js → latex-eFDA2ndR.js} +1 -1
  73. package/ui/dist/assets/{liquid-DAA5lUdB.js → liquid-BqLuL6hU.js} +1 -1
  74. package/ui/dist/assets/{lua-BsloMLJI.js → lua-CKNxXI-W.js} +1 -1
  75. package/ui/dist/assets/{marko-BohJpFVB.js → marko-CM7Qyeyg.js} +1 -1
  76. package/ui/dist/assets/{mdc-DcoaHcos.js → mdc-DQnmyYku.js} +1 -1
  77. package/ui/dist/assets/{nginx-rrqwgIhx.js → nginx-B7PJAw6W.js} +1 -1
  78. package/ui/dist/assets/{nim-bmLsCyNQ.js → nim-CmtAHkVL.js} +1 -1
  79. package/ui/dist/assets/{org-DBqD3zir.js → org-DXNJ0F8G.js} +1 -1
  80. package/ui/dist/assets/{perl-DTcxBbd6.js → perl-DCIWxaEc.js} +1 -1
  81. package/ui/dist/assets/{php-CtncUr2-.js → php-Dc66Nx9S.js} +1 -1
  82. package/ui/dist/assets/{pug-CIvCXkQY.js → pug-C_S0MlW-.js} +1 -1
  83. package/ui/dist/assets/{qml-NvdQJP41.js → qml-VqOrS5Wl.js} +1 -1
  84. package/ui/dist/assets/{r-CTxQa3JJ.js → r-Dl8VhfSg.js} +1 -1
  85. package/ui/dist/assets/{razor-6oObkjRl.js → razor-BEpGMKEn.js} +1 -1
  86. package/ui/dist/assets/{regexp-BoEgdKzz.js → regexp-X9VkDePO.js} +1 -1
  87. package/ui/dist/assets/{rst-C1BRN5ux.js → rst-CQz_65m4.js} +1 -1
  88. package/ui/dist/assets/{ruby-ll_ilmOa.js → ruby-DcUs-Hjd.js} +1 -1
  89. package/ui/dist/assets/{sas-nmRD3ilr.js → sas-0Aya4BIJ.js} +1 -1
  90. package/ui/dist/assets/{scss-DaWVZ0iP.js → scss-BiYXJFG-.js} +1 -1
  91. package/ui/dist/assets/{shellscript-CT8Jobwl.js → shellscript-D_GiG2j2.js} +1 -1
  92. package/ui/dist/assets/{shellsession-CxpwtalA.js → shellsession-CJaG9Dka.js} +1 -1
  93. package/ui/dist/assets/{soy-DEBXlOYH.js → soy-qeuP0wdW.js} +1 -1
  94. package/ui/dist/assets/{sql-3mfonMZP.js → sql-DjtVrjTT.js} +1 -1
  95. package/ui/dist/assets/{stata-DaVf6BI8.js → stata-CTtxT0Lq.js} +1 -1
  96. package/ui/dist/assets/{surrealql-BTM-6O5Q.js → surrealql-CLiRfZ3-.js} +1 -1
  97. package/ui/dist/assets/{svelte-CDPuyZop.js → svelte-DojibMCX.js} +1 -1
  98. package/ui/dist/assets/{templ-CbDJ7JLK.js → templ-BccZaOLv.js} +1 -1
  99. package/ui/dist/assets/{tex-DsKYCwQw.js → tex-DvrCGTlw.js} +1 -1
  100. package/ui/dist/assets/{ts-tags-BYhIdk0p.js → ts-tags-Dv3BB8TA.js} +1 -1
  101. package/ui/dist/assets/{tsx-BjLM2BRB.js → tsx-DogZPQbY.js} +1 -1
  102. package/ui/dist/assets/{twig-DqinkzV-.js → twig-DY6_W6um.js} +1 -1
  103. package/ui/dist/assets/{typescript-DH1pT4ZK.js → typescript-DwP-qhUH.js} +1 -1
  104. package/ui/dist/assets/{typst-BBE0SKCu.js → typst-DW555l0v.js} +1 -1
  105. package/ui/dist/assets/{vue-DIIUy5X_.js → vue-OZGLIqo2.js} +1 -1
  106. package/ui/dist/assets/{vue-html-BLERoY9d.js → vue-html-CjmVpLjs.js} +1 -1
  107. package/ui/dist/assets/{vue-vine-CGIG_xts.js → vue-vine-Cye_YqnL.js} +1 -1
  108. package/ui/dist/assets/{xml-BqbRoduq.js → xml-eBWMgcbX.js} +1 -1
  109. package/ui/dist/assets/{xsl-DZMfS9NA.js → xsl-BdSZmEF6.js} +1 -1
  110. package/ui/dist/assets/{yaml-BM5LLWf8.js → yaml-CbCy6UZM.js} +1 -1
  111. package/ui/dist/index.html +2 -2
  112. package/ui/dist/assets/index-P_fbn8LU.css +0 -1
@@ -1,35 +1,39 @@
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.
1
+ // caret's host-neutral OpenCode core: runPlanReview, the planning steer, the caret
2
+ // version and plans-dir resolution, the warm runner, the toast bodies and tool-call
3
+ // metadata keys, and the update check. It re-uses the bridge's tool name, text, binary
4
+ // resolution and version comparison.
4
5
  // 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.
6
+ // it. Its imports are node builtins and ../bridge/review-bridge.ts only, so loading it
7
+ // never evaluates either host's SDK; test/structure/dependency-placement.test.ts holds it
8
+ // there.
7
9
 
8
- import { spawn } from "node:child_process";
9
- import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
- import { dirname, resolve } from "node:path";
12
+ import { dirname } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
 
14
15
  import {
15
16
  buildEnvelope,
16
17
  type CaretDecision,
17
18
  decisionText,
19
+ isNewer,
18
20
  PLAN_TITLE_INSTRUCTION,
21
+ REVIEW_TOOL,
22
+ readRegularFile,
23
+ resolvePlanSource,
19
24
  runReviewViaCaret,
20
25
  type SpawnRunner,
21
- } from "./review-bridge.ts";
22
-
23
- /** The caret binary the OpenCode plugin 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
- }
26
+ warmCaret,
27
+ } from "../bridge/review-bridge.ts";
28
+
29
+ export {
30
+ isNewer,
31
+ PLAN_ARG_DESCRIPTION,
32
+ REVIEW_TOOL,
33
+ REVIEW_TOOL_DESCRIPTION,
34
+ resolveCaretBin,
35
+ resolvePlanSource,
36
+ } from "../bridge/review-bridge.ts";
33
37
 
34
38
  /** The plugin's own caret version, for the update check, read from the package.json
35
39
  * shipped beside this module. "unknown" when it is unreadable — deliberately UNPARSEABLE
@@ -48,9 +52,6 @@ export function resolveCaretVersion(opts: {
48
52
  }
49
53
  }
50
54
 
51
- /** The plan-review tool the Plan agent calls. */
52
- export const REVIEW_TOOL = "caret_review_plan";
53
-
54
55
  /** OpenCode's built-in primary planning agent — the one agent caret steers toward
55
56
  * the review tool and warms the daemon for. */
56
57
  export const PLANNING_AGENTS = ["plan"] as const;
@@ -123,49 +124,6 @@ export function decisionToast(outcome: ReviewOutcome): ToastBody {
123
124
  }
124
125
  }
125
126
 
126
- type PlanSource = { plan: string; planFilePath?: string };
127
-
128
- /** The plan the review tool submits, from exactly one of its `plan` / `path` args (an
129
- * empty string counts as absent). A `path` resolves against the session directory and
130
- * must be a readable `.md` file — mirroring the core's `isPlanFile`
131
- * (src/plan/canonical-file.ts), repeated here because this module cannot import src/.
132
- * `readFile` returns the text, or undefined when absPath is not a readable regular
133
- * file. */
134
- export function resolvePlanSource(
135
- args: { plan?: string; path?: string },
136
- directory: string,
137
- readFile: (absPath: string) => string | undefined,
138
- ): PlanSource | { error: string } {
139
- const plan = args.plan || undefined;
140
- const path = args.path || undefined;
141
- const notExactlyOne = {
142
- error: `caret: ${REVIEW_TOOL} takes exactly one of \`plan\` or \`path\`. Pass the plan file as \`path\`, or the plan inline as \`plan\`.`,
143
- };
144
- if (path === undefined) return plan === undefined ? notExactlyOne : { plan };
145
- if (plan !== undefined) return notExactlyOne;
146
- const planFilePath = resolve(directory, path);
147
- if (!planFilePath.endsWith(".md")) {
148
- return {
149
- 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\`.`,
150
- };
151
- }
152
- const text = readFile(planFilePath);
153
- if (text === undefined) {
154
- return {
155
- error: `caret: ${REVIEW_TOOL} could not read ${planFilePath} as a regular file. Write the plan there first, or pass it inline as \`plan\`.`,
156
- };
157
- }
158
- return { plan: text, planFilePath };
159
- }
160
-
161
- function readRegularFile(absPath: string): string | undefined {
162
- try {
163
- return statSync(absPath).isFile() ? readFileSync(absPath, "utf-8") : undefined;
164
- } catch {
165
- return undefined;
166
- }
167
- }
168
-
169
127
  // --- version-check toast (EXC-794) -----------------------------------------
170
128
 
171
129
  /** caret's latest-release endpoint. Unauthenticated GitHub API — throttled to at
@@ -179,30 +137,6 @@ const UPDATE_TOAST_MS = 5_000;
179
137
  * OpenCode start. */
180
138
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
181
139
 
182
- /** Semver triple `[major, minor, patch]`, or null when `v` is not `X.Y.Z` (an
183
- * optional leading `v` is stripped; trailing prerelease/build metadata ignored). */
184
- function parseVersionTriple(v: string): [number, number, number] | null {
185
- const m = v
186
- .trim()
187
- .replace(/^v/, "")
188
- .match(/^(\d+)\.(\d+)\.(\d+)/);
189
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
190
- }
191
-
192
- /** True when `latest` is a strictly higher semver than `current`. Inline (no semver
193
- * dep) so the deployed plugin stays self-contained; an unparseable version on
194
- * either side compares false, so the check never nags on something it can't read. */
195
- export function isNewer(latest: string, current: string): boolean {
196
- const a = parseVersionTriple(latest);
197
- const b = parseVersionTriple(current);
198
- if (!a || !b) return false;
199
- const [a0, a1, a2] = a;
200
- const [b0, b1, b2] = b;
201
- if (a0 !== b0) return a0 > b0;
202
- if (a1 !== b1) return a1 > b1;
203
- return a2 > b2;
204
- }
205
-
206
140
  /** The version (v-stripped) and release-page URL from GitHub's /releases/latest
207
141
  * JSON, or null when the shape is unusable. */
208
142
  export function parseLatestRelease(json: unknown): { version: string; url: string } | null {
@@ -365,29 +299,8 @@ export function planningSteer(plansDir: string): string {
365
299
  * unit-testable without spawning a process. */
366
300
  export type WarmRunner = (bin: string) => void;
367
301
 
368
- /** Production warm: `caret prewarm`, detached and unref'd so it never holds up the
369
- * turn. Output is discarded — the child logs to caret.log. CARET_AGENT rides along
370
- * because this spawn is what stands the daemon up and the daemon picks its adapter
371
- * from that env; the 'error' handler is mandatory because spawn emits 'error'
372
- * ASYNCHRONOUSLY (ENOENT on a bad bin), where the hook's synchronous try/catch
373
- * cannot see it and an unhandled event would take OpenCode's whole process down. */
374
- export const nodeWarmRunner: WarmRunner = (bin) => {
375
- const child = spawn(bin, ["prewarm"], {
376
- stdio: "ignore",
377
- detached: true,
378
- env: { ...process.env, CARET_AGENT: "opencode" },
379
- });
380
- child.on("error", () => {});
381
- child.unref();
382
- };
383
-
384
- // The review tool's model-facing text. Both hosts' registrations read these, so v1 and
385
- // v2 stay word-for-word identical (plugin-v2.test.ts pins the refusal texts).
386
- export const REVIEW_TOOL_DESCRIPTION =
387
- "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.";
388
-
389
- export const PLAN_ARG_DESCRIPTION =
390
- "The complete plan, as markdown, to present for human review. The inline alternative to `path`.";
302
+ /** Production warm, as the OpenCode adapter. */
303
+ export const nodeWarmRunner: WarmRunner = (bin) => warmCaret(bin, "opencode");
391
304
 
392
305
  export function pathArgDescription(plansDir: string): string {
393
306
  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}/.)`;
@@ -20,9 +20,9 @@
20
20
  // under v1's `plugin` config key or v2's `plugins` key (v2 reads both, legacy `plugin`
21
21
  // first). This module is the `server` half of index.ts's dual default: index.ts imports
22
22
  // it on v1's first `server` call, so a v2 load never evaluates it. Its imports are node
23
- // builtins, caret.core.ts, review-bridge.ts, and @opencode-ai/plugin (resolved by
24
- // OpenCode at runtime). Which ctx/tool/config shapes are live-verified and which are not:
25
- // doc/agents/opencode-integration.md § Verified vs. follow-up.
23
+ // builtins, caret.core.ts, ../bridge/review-bridge.ts, and @opencode-ai/plugin (resolved
24
+ // by OpenCode at runtime). Which ctx/tool/config shapes are live-verified and which are
25
+ // not: doc/agents/opencode-integration.md § Verified vs. follow-up.
26
26
 
27
27
  import { readFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
@@ -30,6 +30,7 @@ import { relative } from "node:path";
30
30
 
31
31
  import { type Hooks, type Plugin, tool } from "@opencode-ai/plugin";
32
32
 
33
+ import { nodeSpawnRunner, type SpawnRunner } from "../bridge/review-bridge.ts";
33
34
  import {
34
35
  decisionToast,
35
36
  isPlanningAgent,
@@ -50,7 +51,6 @@ import {
50
51
  toastBestEffort,
51
52
  type WarmRunner,
52
53
  } from "./caret.core.ts";
53
- import { nodeSpawnRunner, type SpawnRunner } from "./review-bridge.ts";
54
54
 
55
55
  /** OpenCode's plugin client, structurally narrowed to the calls caret makes.
56
56
  * A structural type (rather than importing the SDK client) keeps this robust
@@ -163,7 +163,13 @@ export function createCaretPlugin(
163
163
  plansDir?: string;
164
164
  } = {},
165
165
  ): Plugin {
166
- const bin = opts.bin ?? resolveCaretBin({ env: process.env, importMetaUrl: import.meta.url });
166
+ const bin =
167
+ opts.bin ??
168
+ resolveCaretBin({
169
+ env: process.env,
170
+ importMetaUrl: import.meta.url,
171
+ overrideVar: "CARET_OPENCODE_BIN",
172
+ });
167
173
  const run = opts.run ?? nodeSpawnRunner;
168
174
  const warm = opts.warm ?? nodeWarmRunner;
169
175
  const plansDir =
@@ -8,6 +8,12 @@ import { readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
11
+ import {
12
+ decisionText,
13
+ errorMessage,
14
+ nodeSpawnRunner,
15
+ type SpawnRunner,
16
+ } from "../bridge/review-bridge.ts";
11
17
  import {
12
18
  CARET_DECISION_KEY,
13
19
  CARET_URL_KEY,
@@ -24,7 +30,6 @@ import {
24
30
  type WarmRunner,
25
31
  } from "./caret.core.ts";
26
32
  import { editChecks, type Rule } from "./permission.ts";
27
- import { decisionText, nodeSpawnRunner, type SpawnRunner } from "./review-bridge.ts";
28
33
 
29
34
  /** Appended last, it outranks a config deny-all: v2 disables a tool only when the last
30
35
  * rule naming it is a `*` deny. */
@@ -210,12 +215,11 @@ export function createCaretSetup(opts: {
210
215
  return { content, metadata: { [CARET_DECISION_KEY]: decision.behavior } };
211
216
  } catch (error) {
212
217
  if (declined !== undefined && error === declined) throw error;
213
- const message = error instanceof Error ? error.message : String(error);
214
218
  return {
215
219
  content: decisionText(
216
220
  {
217
221
  behavior: "deny",
218
- feedback: `caret: review failed to run (${message}) — denying to fail safe.`,
222
+ feedback: `caret: review failed to run (${errorMessage(error)}) — denying to fail safe.`,
219
223
  },
220
224
  REVIEW_TOOL,
221
225
  ),
@@ -284,7 +288,11 @@ export function createCaretSetup(opts: {
284
288
 
285
289
  /** The v2 `setup` OpenCode loads, wired to the production runners. */
286
290
  const setup = createCaretSetup({
287
- bin: resolveCaretBin({ env: process.env, importMetaUrl: import.meta.url }),
291
+ bin: resolveCaretBin({
292
+ env: process.env,
293
+ importMetaUrl: import.meta.url,
294
+ overrideVar: "CARET_OPENCODE_BIN",
295
+ }),
288
296
  run: nodeSpawnRunner,
289
297
  warm: nodeWarmRunner,
290
298
  plansDir: resolvePlansDir({
@@ -10,8 +10,7 @@
10
10
  // toasts. `theme` and `renderer` are optional too: without them the pending row renders in
11
11
  // OpenTUI's default colours, or opens on every mouse-up, selection or not.
12
12
 
13
- import { spawn } from "node:child_process";
14
-
13
+ import { type DetachedSpawn, nodeDetachedSpawn } from "../bridge/review-bridge.ts";
15
14
  import {
16
15
  CARET_DECISION_KEY,
17
16
  CARET_URL_KEY,
@@ -94,21 +93,6 @@ type CaretCommand = {
94
93
  run: () => void;
95
94
  };
96
95
 
97
- /** Spawns `bin args` detached and unwaited; `onError` hears a child that fails to start
98
- * or exits non-zero. */
99
- export type DetachedSpawn = (bin: string, args: string[], onError: (err: Error) => void) => void;
100
-
101
- export const nodeDetachedSpawn: DetachedSpawn = (bin, args, onError) => {
102
- // spawn reports ENOENT asynchronously; unhandled, that 'error' takes OpenCode down.
103
- const child = spawn(bin, args, { stdio: "ignore", detached: true });
104
- child.on("error", onError);
105
- // band-link exits 0 on its own failures, so non-zero means caret never ran.
106
- child.on("exit", (code) => {
107
- if (code) onError(new Error(`exited with status ${code}`));
108
- });
109
- child.unref();
110
- };
111
-
112
96
  /** The `@opentui/solid` reconciler calls the pending row takes. */
113
97
  type OpenTuiReconciler = {
114
98
  createElement: (tag: "box" | "text" | "span" | "b") => unknown;
@@ -336,7 +320,11 @@ function pendingRow(
336
320
  const loadOpenTui = (): Promise<OpenTuiReconciler> => import("@opentui/solid");
337
321
  const setup = createCaretTui({
338
322
  checkUpdate: productionUpdateCheck,
339
- bin: resolveCaretBin({ env: process.env, importMetaUrl: import.meta.url }),
323
+ bin: resolveCaretBin({
324
+ env: process.env,
325
+ importMetaUrl: import.meta.url,
326
+ overrideVar: "CARET_OPENCODE_BIN",
327
+ }),
340
328
  spawn: nodeDetachedSpawn,
341
329
  loadOpenTui,
342
330
  });
@@ -3,7 +3,7 @@ description: Demo caret by submitting a short plan about this repo to caret's re
3
3
  agent: plan
4
4
  ---
5
5
 
6
- The demo template is at the end of this command. Follow its leading HTML comment exactly
6
+ The demo template is at the end of this message. Follow its leading HTML comment exactly
7
7
  to fill the slots, then call the `caret_review_plan` tool with everything after that
8
8
  comment as the `plan` argument. Do **no** other research and edit **no** file — this is
9
9
  only to exercise caret's plan-review flow.
@@ -44,7 +44,11 @@ one. What each id means:
44
44
  | `daemon-lock` | the lock names a dead pid, or a port caret is not configured to bind | the check's remedy verbatim |
45
45
  | `agent-install` | the agent the check names does not have caret enabled | `caret install` |
46
46
  | `log-errors` | a live log wrote an NDJSON error record in the last 24h; the detail names which log and when it last erred | step 4 |
47
+ | `opencode-host` | an `opencode` on PATH is below its major's floor (1.3.4 for v1, 2.0.29 for v2), or caret sits in a key or config file that OpenCode doesn't load | the check's remedy verbatim |
47
48
  | `opencode-caret-version` | OpenCode would load a caret behind the published one | the check's remedy verbatim |
49
+ | `pi-host` | `pi` can't run, is older than 1.5.0, or is the pre-rename `@mariozechner/pi-coding-agent` | the check's remedy verbatim |
50
+ | `pi-caret-entry` | pi lists caret more than once, filters out its extension, or lists it without its package installed | the check's remedy verbatim |
51
+ | `pi-caret-version` | pi would load a caret behind the published one | the check's remedy verbatim |
48
52
 
49
53
  An `unknown` check is not a failure: it names its own `reason` — no network, an unreadable
50
54
  config, or a report section that never collected, which also shows as an `error` line and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@macintacos/caret",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A Claude Code plugin that replaces the terminal plan-approval prompt with a local web UI for inline review, annotation, and approval.",
@@ -9,6 +9,15 @@
9
9
  ".": "./opencode/index.ts",
10
10
  "./tui": "./opencode/caret.tui.ts"
11
11
  },
12
+ "pi": {
13
+ "extensions": [
14
+ "./pi/caret.ts"
15
+ ],
16
+ "changelogPath": ""
17
+ },
18
+ "keywords": [
19
+ "pi-package"
20
+ ],
12
21
  "bin": {
13
22
  "caret": "./bin/caret"
14
23
  },
@@ -28,6 +37,8 @@
28
37
  "hooks/",
29
38
  "commands/",
30
39
  "opencode/",
40
+ "pi/",
41
+ "bridge/",
31
42
  "templates/",
32
43
  "mod/hooks/band.tsx",
33
44
  "mod/band.d.ts",
@@ -49,6 +60,9 @@
49
60
  "@codemirror/state": "^6.6.0",
50
61
  "@codemirror/view": "^6.43.0",
51
62
  "@commander-js/extra-typings": "^15.0.0",
63
+ "@earendil-works/pi-ai": "^1.5.1",
64
+ "@earendil-works/pi-coding-agent": "^1.5.0",
65
+ "@earendil-works/pi-tui": "^1.5.1",
52
66
  "@happy-dom/global-registrator": "^21.0.4",
53
67
  "@internationalized/date": "^3.12.2",
54
68
  "@laststance/tailwind-suggest-canonical-classes": "^0.1.1",
@@ -70,10 +84,10 @@
70
84
  "happy-dom": "^21.0.4",
71
85
  "jsonc-parser": "^3.3.1",
72
86
  "listr2": "^11.0.0",
73
- "marked": "^18.0.4",
87
+ "marked": "^18.1.3",
74
88
  "pino": "^10.3.1",
75
89
  "pino-pretty": "^13.1.3",
76
- "semver": "^7.8.1",
90
+ "semver": "^7.8.6",
77
91
  "shiki": "^4.4.3",
78
92
  "smol-toml": "^1.8.0",
79
93
  "svelte": "^5.56.9",
@@ -82,6 +96,7 @@
82
96
  "tailwind-variants": "^3.2.2",
83
97
  "tailwindcss": "^4.3.3",
84
98
  "tw-animate-css": "^1.4.0",
99
+ "typebox": "^1.3.27",
85
100
  "typescript": "^6.0.3",
86
101
  "vite": "^8.2.1",
87
102
  "xss": "^1.0.15",
package/pi/caret.ts ADDED
@@ -0,0 +1,145 @@
1
+ // caret's pi extension. pi loads this file through jiti on Node, from `package.json`
2
+ // `pi.extensions`. It may import only node builtins, pi's host-provided packages, its own
3
+ // pi/ modules, and ../bridge/review-bridge.ts — and nothing it reaches may use bun's
4
+ // runtime API, since pi runs on Node.
5
+
6
+ import { writeSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ import { getAgentDir, VERSION } from "@earendil-works/pi-coding-agent";
11
+
12
+ import {
13
+ type DetachedSpawn,
14
+ detachedSpawnWith,
15
+ isNewer,
16
+ nodeSpawnRunner,
17
+ parseVersionTriple,
18
+ resolveCaretBin,
19
+ type SpawnRunner,
20
+ warmCaret,
21
+ } from "../bridge/review-bridge.ts";
22
+ import { DEMO_COMMAND, DEMO_TEMPLATE, type DemoApi, installDemo } from "./demo.ts";
23
+ import { type DoctorApi, installDoctor } from "./doctor.ts";
24
+ import { installNotice, type NoticeApi } from "./notice.ts";
25
+ import { installPlanning, type PlanningApi } from "./plan.ts";
26
+ import { reviewStore } from "./state.ts";
27
+ import { type PiTool, reviewTool } from "./tool.ts";
28
+
29
+ /** Keep equal to src/adapters/pi/host.ts's PI_FLOOR (test/pi/caret.test.ts pins it);
30
+ * pi/ can't import src/. */
31
+ export const PI_FLOOR = "1.5.0";
32
+
33
+ /** The slice of pi's session context caret calls; pinned by test/pi/sdk-conformance.ts. */
34
+ export type PiNotifyContext = {
35
+ ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
36
+ };
37
+
38
+ /** The slice of pi's extension API caret calls; pinned by test/pi/sdk-conformance.ts. */
39
+ export type PiApi = PlanningApi &
40
+ NoticeApi &
41
+ DemoApi &
42
+ DoctorApi & {
43
+ registerTool(tool: PiTool): void;
44
+ on(event: "session_start", handler: (event: unknown, ctx: PiNotifyContext) => void): void;
45
+ on(event: "session_shutdown", handler: () => void): void;
46
+ };
47
+
48
+ export interface CaretHost {
49
+ version: string;
50
+ /** This copy's module URL: its identity against other installed copies, and the base
51
+ * `../bin/caret` and the demo template resolve against. */
52
+ entryUrl: string;
53
+ /** globalThis in production: survives `/reload`, which loads a fresh module instance. */
54
+ holder: Record<symbol, unknown>;
55
+ env: Record<string, string | undefined>;
56
+ run: SpawnRunner;
57
+ /** pi's agent dir; the plans folder sits under it. */
58
+ agentDir: string;
59
+ /** Spawns `caret prewarm` for `bin`; best-effort. */
60
+ warm: (bin: string) => void;
61
+ /** Starts `caret band-link` detached. */
62
+ spawn: DetachedSpawn;
63
+ /** Writes to the real stdout, for print mode's report. */
64
+ stdout: (text: string) => void;
65
+ }
66
+
67
+ const OWNER = Symbol.for("caret.pi.owner");
68
+ const NOTIFIED = Symbol.for("caret.pi.notified");
69
+
70
+ /** Whether `version` parses and is at least PI_FLOOR. */
71
+ export function meetsFloor(version: string): boolean {
72
+ return parseVersionTriple(version) !== null && !isNewer(PI_FLOOR, version);
73
+ }
74
+
75
+ /** One caret warning per process, whichever refusal comes first. */
76
+ function notifyOnce(pi: PiApi, holder: Record<symbol, unknown>, message: string): void {
77
+ pi.on("session_start", (_event, ctx) => {
78
+ if (holder[NOTIFIED]) return;
79
+ holder[NOTIFIED] = true;
80
+ ctx.ui.notify(message, "warning");
81
+ });
82
+ }
83
+
84
+ /** Registers the review tool, the planning mode, the pending-plan notice, and the
85
+ * `/caret:*` commands unless pi is below PI_FLOOR or another installed copy already owns
86
+ * `Symbol.for("caret.pi.owner")`. The owner is keyed by `entryUrl`, which differs between
87
+ * installed copies and is stable across `/reload`. It is released on `session_shutdown`
88
+ * so a surviving copy takes over after the owner is removed. Either refusal warns once
89
+ * per process. */
90
+ export function installCaret(pi: PiApi, host: CaretHost): void {
91
+ const { version, entryUrl, holder, env, run } = host;
92
+ if (!meetsFloor(version)) {
93
+ notifyOnce(
94
+ pi,
95
+ holder,
96
+ `caret needs pi ${PI_FLOOR} or later (this is pi ${version}), so its review tool is off. Update pi to use caret.`,
97
+ );
98
+ return;
99
+ }
100
+ holder[OWNER] ??= entryUrl;
101
+ const owner = holder[OWNER];
102
+ if (owner !== entryUrl) {
103
+ notifyOnce(
104
+ pi,
105
+ holder,
106
+ `caret is loaded twice: the copy at ${fileURLToPath(String(owner))} runs and the copy at ${fileURLToPath(entryUrl)} stays off. Remove one, then run /reload.`,
107
+ );
108
+ return;
109
+ }
110
+ pi.on("session_shutdown", () => {
111
+ if (holder[OWNER] === entryUrl) delete holder[OWNER];
112
+ });
113
+ const bin = resolveCaretBin({ env, importMetaUrl: entryUrl, overrideVar: "CARET_PI_BIN" });
114
+ const store = reviewStore(holder);
115
+ pi.registerTool(reviewTool({ bin, run, store }));
116
+ installPlanning(pi, {
117
+ store,
118
+ plansDir: join(host.agentDir, "plans"),
119
+ warm: () => host.warm(bin),
120
+ });
121
+ installNotice(pi, { store, bin, spawn: host.spawn });
122
+ installDemo(pi, {
123
+ commandPath: fileURLToPath(new URL(DEMO_COMMAND, entryUrl)),
124
+ templatePath: fileURLToPath(new URL(DEMO_TEMPLATE, entryUrl)),
125
+ isPlanning: (id) => store.get(id).planning,
126
+ });
127
+ installDoctor(pi, { bin, run, env, stdout: host.stdout });
128
+ }
129
+
130
+ export default function caret(pi: PiApi): void {
131
+ installCaret(pi, {
132
+ version: VERSION,
133
+ entryUrl: import.meta.url,
134
+ holder: globalThis as unknown as Record<symbol, unknown>,
135
+ env: process.env,
136
+ run: nodeSpawnRunner,
137
+ agentDir: getAgentDir(),
138
+ warm: (bin) => warmCaret(bin, "pi"),
139
+ spawn: detachedSpawnWith({ CARET_AGENT: "pi" }),
140
+ // ponytail: fd 1, not process.stdout, since print mode reroutes process.stdout.write
141
+ // to stderr. A non-blocking stdout can throw EAGAIN on a report past the pipe buffer;
142
+ // retry as pi's own raw writer does if reports grow.
143
+ stdout: (text) => writeSync(1, text),
144
+ });
145
+ }
package/pi/demo.ts ADDED
@@ -0,0 +1,79 @@
1
+ // `/caret:demo` for pi: sends the agent OpenCode's `/caret:demo` prompt with caret's demo
2
+ // template in place of its marker, both read from beside the package on every run, so a
3
+ // new user sees a review end to end.
4
+
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ import { errorMessage } from "../bridge/review-bridge.ts";
8
+ import { sendAndWait } from "./follow-up.ts";
9
+
10
+ /** The demo template and OpenCode's `/caret:demo` command, relative to the extension
11
+ * entry `pi/caret.ts`. Both hosts submit through the same review tool, so the command's
12
+ * body is pi's prompt too. */
13
+ export const DEMO_TEMPLATE = "../templates/demo.md";
14
+ export const DEMO_COMMAND = "../opencode/commands/demo.md";
15
+
16
+ const TEMPLATE_MARKER = "__CARET_DEMO_TEMPLATE__";
17
+ const FRONTMATTER = /^---\n[\s\S]*?\n---\n+/;
18
+
19
+ /** `command`'s body with `template` taken literally in place of its marker. */
20
+ const demoPrompt = (command: string, template: string): string =>
21
+ command.replace(FRONTMATTER, "").replace(TEMPLATE_MARKER, () => template);
22
+
23
+ export type DemoCommandContext = {
24
+ ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
25
+ waitForIdle(): Promise<void>;
26
+ sessionManager: { getSessionId(): string };
27
+ };
28
+
29
+ /** The slice of pi's extension API `/caret:demo` calls; pinned by
30
+ * test/pi/sdk-conformance.ts. */
31
+ export type DemoApi = {
32
+ registerCommand(
33
+ name: string,
34
+ options: {
35
+ description: string;
36
+ handler: (args: string, ctx: DemoCommandContext) => Promise<void>;
37
+ },
38
+ ): void;
39
+ sendUserMessage(content: string, options: { deliverAs: "followUp" }): void;
40
+ };
41
+
42
+ /** Registers `/caret:demo`, which reads the command at `commandPath` and the template at
43
+ * `templatePath` on every run and refuses while the session is planning, where approving
44
+ * the demo could end planning for the real task. */
45
+ export function installDemo(
46
+ pi: DemoApi,
47
+ deps: {
48
+ commandPath: string;
49
+ templatePath: string;
50
+ isPlanning: (sessionId: string) => boolean;
51
+ },
52
+ ): void {
53
+ const { commandPath, templatePath, isPlanning } = deps;
54
+ pi.registerCommand("caret:demo", {
55
+ description: "Demo caret: review a short plan about this repo",
56
+ handler: async (_args, ctx) => {
57
+ if (isPlanning(ctx.sessionManager.getSessionId())) {
58
+ ctx.ui.notify(
59
+ "caret's demo is off while planning; run /caret:plan to leave planning first.",
60
+ "warning",
61
+ );
62
+ return;
63
+ }
64
+ let prompt: string;
65
+ try {
66
+ const [command, template] = await Promise.all([
67
+ readFile(commandPath, "utf-8"),
68
+ readFile(templatePath, "utf-8"),
69
+ ]);
70
+ prompt = demoPrompt(command, template);
71
+ } catch (err) {
72
+ // The error names the file that failed.
73
+ ctx.ui.notify(`caret couldn't read its demo prompt: ${errorMessage(err)}`, "error");
74
+ return;
75
+ }
76
+ await sendAndWait(pi, ctx, prompt);
77
+ },
78
+ });
79
+ }