@macintacos/caret 1.7.0 → 1.8.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 (113) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +10 -7
  3. package/dist/cli.js +849 -655
  4. package/opencode/caret.core.ts +14 -6
  5. package/opencode/caret.plugin.v2.ts +41 -15
  6. package/opencode/caret.tui.ts +290 -17
  7. package/opencode/opentui-solid.d.ts +8 -0
  8. package/opencode/permission.ts +3 -44
  9. package/opencode/review-bridge.ts +19 -4
  10. package/package.json +2 -2
  11. package/ui/dist/assets/{angular-html-DbhcQrGG.js → angular-html-BXFKQGXA.js} +1 -1
  12. package/ui/dist/assets/{angular-ts-Cum7bwhS.js → angular-ts-BteBaZbv.js} +1 -1
  13. package/ui/dist/assets/{apl-C7ktlLa5.js → apl-Boa3o4y5.js} +1 -1
  14. package/ui/dist/assets/{astro-VpFubXJn.js → astro-BeBp_x10.js} +1 -1
  15. package/ui/dist/assets/{blade-_p0dlQ-o.js → blade-BsWYzT9U.js} +1 -1
  16. package/ui/dist/assets/{c-BRhF-xdb.js → c-CiG9bK25.js} +1 -1
  17. package/ui/dist/assets/{chapel-BgKEyCON.js → chapel-7fNKKqpl.js} +1 -1
  18. package/ui/dist/assets/{cobol-r_HdzaUV.js → cobol-B1HBnyA6.js} +1 -1
  19. package/ui/dist/assets/{coffee-BnTujJ8N.js → coffee-BR4Q34UF.js} +1 -1
  20. package/ui/dist/assets/{cpp-BlwBQZ9R.js → cpp-C3wngvr5.js} +1 -1
  21. package/ui/dist/assets/{crystal-Dfws0EvT.js → crystal-DiIiTPBe.js} +1 -1
  22. package/ui/dist/assets/{css-BxzS_9W2.js → css-BX3gLifs.js} +1 -1
  23. package/ui/dist/assets/{dist-CCaNNKok.js → dist-50vAtYCv.js} +1 -1
  24. package/ui/dist/assets/{dist-CWEzdJiQ.js → dist-BoVp9dqK.js} +1 -1
  25. package/ui/dist/assets/{dist-CrA0chnO.js → dist-BqrHiigI.js} +1 -1
  26. package/ui/dist/assets/{dist-BurvsmJN.js → dist-C79XSdY8.js} +1 -1
  27. package/ui/dist/assets/{dist-BW8E7IFp.js → dist-CMPsnKBn.js} +2 -2
  28. package/ui/dist/assets/{dist-BtudxTi6.js → dist-CP875olq.js} +1 -1
  29. package/ui/dist/assets/{dist-Ccfk4NCv.js → dist-D7HvdIC5.js} +1 -1
  30. package/ui/dist/assets/{dist-C5H8xzaz.js → dist-DXddG2tW.js} +1 -1
  31. package/ui/dist/assets/{dist-kZ0GiOwO.js → dist-DaLa-fRf.js} +1 -1
  32. package/ui/dist/assets/{dist-C0kJT6Lc.js → dist-H9wPekMH.js} +1 -1
  33. package/ui/dist/assets/{dist-BzruqOsf.js → dist-NwnWQZNa.js} +1 -1
  34. package/ui/dist/assets/{dist-rTQz7rmj.js → dist-SYZxBIS_.js} +1 -1
  35. package/ui/dist/assets/{dist-COFU7QsL.js → dist-anXz8tLl.js} +1 -1
  36. package/ui/dist/assets/{dist-BBCKJuIA.js → dist-jDKYW61p.js} +1 -1
  37. package/ui/dist/assets/{dist-ldnZ9m94.js → dist-p9qfYPbt.js} +1 -1
  38. package/ui/dist/assets/{dist-BBGx42PS.js → dist-r7n7YX8s.js} +1 -1
  39. package/ui/dist/assets/{dist--iE_Uraw.js → dist-sf8u9yOd.js} +1 -1
  40. package/ui/dist/assets/{dist-DSyCej7W.js → dist-yYI7sEVm.js} +1 -1
  41. package/ui/dist/assets/{edge-kMKV375d.js → edge-D6eM3HKg.js} +1 -1
  42. package/ui/dist/assets/{elixir-jC73nVO2.js → elixir-BiXwbjRd.js} +1 -1
  43. package/ui/dist/assets/{elm-BkX-ZQK-.js → elm-uPrh5C1H.js} +1 -1
  44. package/ui/dist/assets/{erb-CsTgzHh3.js → erb-C01CNhsq.js} +1 -1
  45. package/ui/dist/assets/{git-rebase-C3e9EoS5.js → git-rebase-BctEis_g.js} +1 -1
  46. package/ui/dist/assets/{glimmer-js-DvtSC46Q.js → glimmer-js-gguWPKCT.js} +1 -1
  47. package/ui/dist/assets/{glimmer-ts-CsMz3ept.js → glimmer-ts-ByonKsDM.js} +1 -1
  48. package/ui/dist/assets/{glsl-BSqxLuLL.js → glsl-Buh4zHgg.js} +1 -1
  49. package/ui/dist/assets/{graphql-DFOyfgu2.js → graphql-DtmB7miI.js} +1 -1
  50. package/ui/dist/assets/{hack-IDWwqJuy.js → hack-qTVQw4Zz.js} +1 -1
  51. package/ui/dist/assets/{haml-DhDWyr66.js → haml-Bt2RhQj7.js} +1 -1
  52. package/ui/dist/assets/{handlebars-n6yGx2-r.js → handlebars-QJ7q0AEL.js} +1 -1
  53. package/ui/dist/assets/{html-DnapKdPk.js → html-Db-D3rXr.js} +1 -1
  54. package/ui/dist/assets/{html-derivative-D5IfM48b.js → html-derivative-DX0FUICr.js} +1 -1
  55. package/ui/dist/assets/{http-BDPi0Fzu.js → http-CF8QPtf7.js} +1 -1
  56. package/ui/dist/assets/hurl-GyiNlLSJ.js +1 -0
  57. package/ui/dist/assets/index-CfQ31NDJ.js +4303 -0
  58. package/ui/dist/assets/index-P_fbn8LU.css +1 -0
  59. package/ui/dist/assets/{java-Crffk4_D.js → java-7GW5fJMj.js} +1 -1
  60. package/ui/dist/assets/{javascript-k6mYfToP.js → javascript-DLle3-Wd.js} +1 -1
  61. package/ui/dist/assets/{jinja-DBbl2lbx.js → jinja-DyZ1srCs.js} +1 -1
  62. package/ui/dist/assets/{jison-CLttg-R0.js → jison-B5gggSJ6.js} +1 -1
  63. package/ui/dist/assets/{json-B-h9fM-h.js → json-CQEsMxfE.js} +1 -1
  64. package/ui/dist/assets/{jsx-Doicuuz6.js → jsx-BP9hEOr_.js} +1 -1
  65. package/ui/dist/assets/julia-CIVTqrrA.js +1 -0
  66. package/ui/dist/assets/{just-CEA3yRxr.js → just-D4NdkezD.js} +1 -1
  67. package/ui/dist/assets/{latex-BbhHfcvt.js → latex-CFviGdV1.js} +1 -1
  68. package/ui/dist/assets/{liquid-C44fO2N7.js → liquid-Dtx11Zuh.js} +1 -1
  69. package/ui/dist/assets/{lua-QlnLKi7-.js → lua-kGMOY_yZ.js} +1 -1
  70. package/ui/dist/assets/marko-OjkhOEN7.js +1 -0
  71. package/ui/dist/assets/{mdc-B7jyRA-3.js → mdc-wyhjzzvx.js} +1 -1
  72. package/ui/dist/assets/{nginx-C5QBw-9w.js → nginx-C6dVmlUS.js} +1 -1
  73. package/ui/dist/assets/{nim-CYyBsOc5.js → nim-BX63w2ay.js} +1 -1
  74. package/ui/dist/assets/{org-lhZNod3T.js → org-DZgxDttS.js} +1 -1
  75. package/ui/dist/assets/{perl-D3PP4FNf.js → perl-JXbylPLY.js} +1 -1
  76. package/ui/dist/assets/{php-4SpRxnxb.js → php-5EbhLfI_.js} +1 -1
  77. package/ui/dist/assets/{pug-DWnInaKi.js → pug-C6Iv2U2H.js} +1 -1
  78. package/ui/dist/assets/{qml-r0QDhCU6.js → qml-CqGs5UjC.js} +1 -1
  79. package/ui/dist/assets/{r-Cw0NLWip.js → r-BKwdj1Ob.js} +1 -1
  80. package/ui/dist/assets/{razor-C5C4Yme1.js → razor-CAmmpIRM.js} +1 -1
  81. package/ui/dist/assets/{regexp-BcY4174s.js → regexp-D-P27PhL.js} +1 -1
  82. package/ui/dist/assets/rst-BtBzILBo.js +1 -0
  83. package/ui/dist/assets/{ruby-3WaChjj1.js → ruby-BFChMOIO.js} +1 -1
  84. package/ui/dist/assets/{sas-DQZ23qju.js → sas-BHer69PY.js} +1 -1
  85. package/ui/dist/assets/{scss-B2aiLKWU.js → scss-B5xzD0OV.js} +1 -1
  86. package/ui/dist/assets/{shellscript-BC3CwnWH.js → shellscript-Dzit1Ttv.js} +1 -1
  87. package/ui/dist/assets/{shellsession-BWYoBj6b.js → shellsession-CMvz-BMu.js} +1 -1
  88. package/ui/dist/assets/{soy-COgXxeAt.js → soy-D7ynJc6J.js} +1 -1
  89. package/ui/dist/assets/{sql-C1jESGE1.js → sql-bRew6PYM.js} +1 -1
  90. package/ui/dist/assets/{stata-BhDPsrCT.js → stata-22_vRW2L.js} +1 -1
  91. package/ui/dist/assets/{surrealql-C8x1KtGL.js → surrealql-C34aKiyl.js} +1 -1
  92. package/ui/dist/assets/{svelte-CPqCsGOT.js → svelte-Dto-0MeJ.js} +1 -1
  93. package/ui/dist/assets/{templ-Cf3ROOCh.js → templ-DtacKQ1n.js} +1 -1
  94. package/ui/dist/assets/{tex-CP0vZv7G.js → tex-CS5XiOvi.js} +1 -1
  95. package/ui/dist/assets/{ts-tags-DASj8WEr.js → ts-tags-BdzU5K8A.js} +1 -1
  96. package/ui/dist/assets/{tsx-DleCywXl.js → tsx-Butg554t.js} +1 -1
  97. package/ui/dist/assets/{twig-Chlh_dlU.js → twig-CZKpRnni.js} +1 -1
  98. package/ui/dist/assets/{typescript-BmrXHOFg.js → typescript-CZA7NJZI.js} +1 -1
  99. package/ui/dist/assets/typst-DIVncoNE.js +1 -0
  100. package/ui/dist/assets/{vue-DiqBK2nm.js → vue-Bn3_aUS9.js} +1 -1
  101. package/ui/dist/assets/{vue-html-qSJAH0He.js → vue-html-B7r3yzLD.js} +1 -1
  102. package/ui/dist/assets/{vue-vine-CwCMGlwz.js → vue-vine-B3JnxtKQ.js} +1 -1
  103. package/ui/dist/assets/{xml-B5tofRe_.js → xml-DKhH88k1.js} +1 -1
  104. package/ui/dist/assets/{xsl-Cvsp-ony.js → xsl-boEEflBZ.js} +1 -1
  105. package/ui/dist/assets/{yaml-B8qT_WhX.js → yaml-B2pr6j9H.js} +1 -1
  106. package/ui/dist/index.html +3 -3
  107. package/ui/dist/assets/hurl-B3mNDy-2.js +0 -1
  108. package/ui/dist/assets/index-CBhfbXzU.js +0 -4291
  109. package/ui/dist/assets/index-DQnrXSH7.css +0 -1
  110. package/ui/dist/assets/julia-DrUq5Tw4.js +0 -1
  111. package/ui/dist/assets/marko-XfLSxn-3.js +0 -1
  112. package/ui/dist/assets/rst-nGONHPYl.js +0 -1
  113. package/ui/dist/assets/typst-W0jO6w7-.js +0 -1
@@ -20,7 +20,7 @@ import {
20
20
  type SpawnRunner,
21
21
  } from "./review-bridge.ts";
22
22
 
23
- /** The caret binary the review tool spawns. Env override wins; else the binary that
23
+ /** The caret binary the OpenCode plugin spawns. Env override wins; else the binary that
24
24
  * ships beside this module in the npm package. */
25
25
  export function resolveCaretBin(opts: {
26
26
  env: Record<string, string | undefined>;
@@ -71,7 +71,8 @@ export function isPlanningAgent(agent: string | undefined): boolean {
71
71
  const REVIEW_TOAST_TITLE = "caret: review this plan";
72
72
 
73
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. */
74
+ * `ToastOptions`. v1 forwards it as-is, so a host-only field (v2's `sessionID`) is
75
+ * added by that host's sink, not here. */
75
76
  export type ToastBody = {
76
77
  title?: string;
77
78
  message: string;
@@ -406,12 +407,16 @@ export type ReviewHost = {
406
407
  /** Fail-open: false when the session is unreadable. */
407
408
  isSubagent: () => Promise<boolean>;
408
409
  /** 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>;
410
+ * must allow it. false = denied or the check failed; a string is the host's own
411
+ * refusal, returned instead of the not-permitted text. */
412
+ canEdit: (planFilePath: string) => Promise<boolean | string>;
411
413
  /** Aborting it kills the `caret review` child. */
412
414
  signal?: AbortSignal;
413
415
  /** Fires once with the review URL while the review is pending. */
414
416
  onUrl?: (url: string) => void;
417
+ /** Optional approve-variant ids this host can honor, sent as the envelope's
418
+ * `approve_variants`; v1 declares none. */
419
+ approveVariants?: readonly string[];
415
420
  };
416
421
 
417
422
  /** One review-tool call, host-neutral: subagent refusal, plan source, the `path` edit
@@ -425,13 +430,16 @@ export async function runPlanReview(
425
430
  if (await host.isSubagent()) return { text: SUBAGENT_REFUSAL };
426
431
  const source = resolvePlanSource(args, host.directory, readRegularFile);
427
432
  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) };
433
+ if (source.planFilePath) {
434
+ const editVerdict = await host.canEdit(source.planFilePath);
435
+ if (typeof editVerdict === "string") return { text: editVerdict };
436
+ if (!editVerdict) return { text: notPermittedToEdit(source.planFilePath, deps.plansDir) };
430
437
  }
431
438
  const envelope = buildEnvelope(source.plan, {
432
439
  sessionID: host.sessionID,
433
440
  directory: host.directory,
434
441
  planFilePath: source.planFilePath,
442
+ approveVariants: host.approveVariants,
435
443
  });
436
444
  const decision = await runReviewViaCaret(envelope, {
437
445
  command: [deps.bin, "review"],
@@ -23,7 +23,7 @@ import {
23
23
  runPlanReview,
24
24
  type WarmRunner,
25
25
  } from "./caret.core.ts";
26
- import { editChecks, editPermitted, type Rule } from "./permission.ts";
26
+ import { editChecks, type Rule } from "./permission.ts";
27
27
  import { decisionText, nodeSpawnRunner, type SpawnRunner } from "./review-bridge.ts";
28
28
 
29
29
  /** Appended last, it outranks a config deny-all: v2 disables a tool only when the last
@@ -46,6 +46,13 @@ export function withPlanAllow(
46
46
  return [...sessionRules, PLAN_ALLOW_RULE];
47
47
  }
48
48
 
49
+ /** The `path` refusal on a v2 before 2.0.29, which has no `permission.assert` to ask
50
+ * whether caret may rewrite the file. Keep 2.0.29 in sync with `V2_FLOOR` in
51
+ * src/adapters/opencode/host.ts. */
52
+ function cannotAskToEdit(planFilePath: string): string {
53
+ return `caret: ${REVIEW_TOOL} can't ask the running OpenCode whether it may edit ${planFilePath}, which a path review rewrites. Pass the plan inline as \`plan\`; path reviews need OpenCode 2.0.29 or later, which the user can upgrade to.`;
54
+ }
55
+
49
56
  type SessionInfo = {
50
57
  location: { directory: string };
51
58
  parentID?: string;
@@ -73,12 +80,22 @@ type ReviewTool = {
73
80
  ) => Promise<{ content: string; metadata?: Record<string, string> }>;
74
81
  };
75
82
 
83
+ /** The approve-variant id for "Approve & build"; must equal BUILD_VARIANT.id in
84
+ * src/adapters/opencode/approve.ts. */
85
+ export const APPROVE_AND_BUILD = "build";
86
+
87
+ /** Appended to an approval's tool result when the switch to build fails, so the model asks
88
+ * rather than implements. */
89
+ export const BUILD_SWITCH_FAILED =
90
+ "caret could not switch this session to the build agent, so it is still in the plan agent. Ask the user to switch to build before implementing.";
91
+
76
92
  /** The slice of v2's plugin `Context` caret uses. */
77
93
  export type SetupContext = {
78
94
  location: { directory: string; project: { directory: string } };
79
95
  session: {
80
96
  get: (input: { sessionID: string }) => Promise<SessionInfo>;
81
97
  update: (input: { sessionID: string; permissions: Rule[] }) => Promise<unknown>;
98
+ switchAgent: (input: { sessionID: string; agent: string }) => Promise<unknown>;
82
99
  hook: {
83
100
  (name: "context", callback: (event: ContextEvent) => Promise<void>): Promise<unknown>;
84
101
  (name: "prompt", callback: (event: PromptEvent) => Promise<void>): Promise<unknown>;
@@ -117,12 +134,24 @@ export function createCaretSetup(opts: {
117
134
  run: SpawnRunner;
118
135
  warm: WarmRunner;
119
136
  plansDir: string;
137
+ logError: (message: string, error: unknown) => void;
120
138
  }): (ctx: SetupContext) => Promise<void> {
121
- const { bin, run, warm, plansDir } = opts;
139
+ const { bin, run, warm, plansDir, logError } = opts;
122
140
 
123
141
  return async (ctx) => {
124
142
  const readSession = (sessionID: string) => ctx.session.get({ sessionID });
125
143
 
144
+ // Catches its own failure: execute's outer catch would turn a throw into a fail-safe deny.
145
+ async function switchToBuild(sessionID: string, approvalText: string): Promise<string> {
146
+ try {
147
+ await ctx.session.switchAgent({ sessionID, agent: "build" });
148
+ return approvalText;
149
+ } catch (error) {
150
+ logError(`caret: could not switch session ${sessionID} to the build agent`, error);
151
+ return `${approvalText}\n\n${BUILD_SWITCH_FAILED}`;
152
+ }
153
+ }
154
+
126
155
  async function execute(
127
156
  input: unknown,
128
157
  context: ToolContext,
@@ -160,29 +189,25 @@ export function createCaretSetup(opts: {
160
189
  throw error;
161
190
  }
162
191
  }
163
- // Session rules can deny, so an unreadable session cannot prove allow.
164
- if (!session) return false;
165
- try {
166
- const agent = await ctx.agent.get({ agentID: context.agent });
167
- return editPermitted(planFilePath, location, {
168
- agent: agent.data.permissions,
169
- session: session.permissions ?? [],
170
- });
171
- } catch {
172
- return false;
173
- }
192
+ return cannotAskToEdit(planFilePath);
174
193
  },
175
194
  signal: context.signal,
176
- // The TUI half toasts from these progress and result keys.
195
+ // The TUI half reads these progress and result keys.
177
196
  onUrl: (url) => {
178
197
  context.progress({ [CARET_URL_KEY]: url }).catch(() => {});
179
198
  },
199
+ // Only a planning session has a build agent to hand off to.
200
+ ...(isPlanningAgent(context.agent) && { approveVariants: [APPROVE_AND_BUILD] }),
180
201
  },
181
202
  { bin, run, plansDir },
182
203
  );
183
204
  // An abort's fail-safe deny is no reviewer's decision; the TUI reads a missing key as cancelled.
184
205
  if (!decision || context.signal.aborted) return { content: text };
185
- return { content: text, metadata: { [CARET_DECISION_KEY]: decision.behavior } };
206
+ const content =
207
+ decision.behavior === "allow" && decision.acceptMode === APPROVE_AND_BUILD
208
+ ? await switchToBuild(context.sessionID, text)
209
+ : text;
210
+ return { content, metadata: { [CARET_DECISION_KEY]: decision.behavior } };
186
211
  } catch (error) {
187
212
  if (declined !== undefined && error === declined) throw error;
188
213
  const message = error instanceof Error ? error.message : String(error);
@@ -269,5 +294,6 @@ const setup = createCaretSetup({
269
294
  // v2's plan agent may edit only this dir, and v2 roots it at $HOME, not XDG.
270
295
  defaultDir: join(homedir(), ".opencode", "plan"),
271
296
  }),
297
+ logError: (message, error) => console.error(message, error),
272
298
  });
273
299
  export default setup;
@@ -1,8 +1,16 @@
1
- // caret's OpenCode v2 TUI plugin: the review-link, decision, and update toasts. v2's server
2
- // `Context` has no toast surface, so v2 loads this module from package.json
3
- // `exports["./tui"]` as `{ id, setup }`. v1 keeps its own toasts in `server()`; the no-op
4
- // `tui` exists because v1's installer may register this module as a v1 TUI plugin, and
5
- // v1's TUI loader throws on a default without one.
1
+ // caret's OpenCode v2 TUI plugin: the review-link, decision, and update toasts, the
2
+ // `/caret` command that opens the session's waiting plan, and the pending line above a
3
+ // waiting session's prompt. v2's server `Context` has no toast surface, so v2 loads this
4
+ // module from package.json `exports["./tui"]` as `{ id, setup }`. v1 keeps its own toasts
5
+ // in `server()`; the no-op `tui` exists because v1's installer may register this module as
6
+ // a v1 TUI plugin, and v1's TUI loader throws on a default without one. Because v1 can load
7
+ // this module, `@opentui/solid` is imported only dynamically, from `setup`, which v1 never
8
+ // calls. `keymap`, `ui.router`, `ui.slot`, and `storage` stay optional: a feature that
9
+ // needs one runs only when the host has it, so a context without them still gets caret's
10
+ // toasts. `theme` and `renderer` are optional too: without them the pending row renders in
11
+ // OpenTUI's default colours, or opens on every mouse-up, selection or not.
12
+
13
+ import { spawn } from "node:child_process";
6
14
 
7
15
  import {
8
16
  CARET_DECISION_KEY,
@@ -10,13 +18,21 @@ import {
10
18
  decisionToast,
11
19
  productionUpdateCheck,
12
20
  type ReviewOutcome,
21
+ resolveCaretBin,
13
22
  reviewLinkToast,
14
23
  type ToastBody,
15
24
  type ToastSink,
16
25
  toastBestEffort,
17
26
  } from "./caret.core.ts";
18
27
 
19
- type ToolEvent = { data: { id: string; metadata?: Record<string, unknown> } };
28
+ type ToolEvent = {
29
+ data: { id: string; sessionID: string; metadata?: Record<string, unknown> };
30
+ };
31
+
32
+ /** v2's `ToastOptions` as caret uses it: the shared body plus the session it is about,
33
+ * which OpenCode offers to open when that session's family is not open, titling an
34
+ * untitled toast with the session's title. */
35
+ type TuiToast = ToastBody & { sessionID?: string };
20
36
 
21
37
  /** The slice of v2's TUI context caret uses, narrow so tests build it without casts;
22
38
  * test/opencode/sdk-conformance.ts pins it to v2's real `Context`. */
@@ -27,46 +43,303 @@ export type TuiContext = {
27
43
  handler: (event: ToolEvent) => void,
28
44
  ) => () => void;
29
45
  };
30
- ui: { toast: { show: (options: ToastBody) => void } };
46
+ ui: {
47
+ toast: { show: (options: TuiToast) => void };
48
+ slot?: (claim: ComposerClaim) => () => void;
49
+ router?: { current: () => Route };
50
+ };
51
+ keymap?: {
52
+ layer: (factory: () => { mode: "global"; commands: CaretCommand[] }) => void;
53
+ shortcuts?: (id: string) => readonly string[];
54
+ };
55
+ theme?: TuiTheme;
56
+ renderer?: { getSelection?: () => { getSelectedText: () => string } | null };
57
+ storage?: {
58
+ memory: (
59
+ key: string,
60
+ options: { readonly initial: PendingBySession },
61
+ ) => readonly [PendingBySession, (mutation: (draft: PendingBySession) => void) => void];
62
+ };
63
+ };
64
+
65
+ /** The host theme's tokens the pending row paints with; each is an opaque OpenTUI colour. */
66
+ type TuiTheme = {
67
+ text: { base: unknown; muted: unknown; feedback: { warning: { base: unknown } } };
68
+ background: { raised: { base: unknown } };
69
+ border: { base: unknown };
70
+ };
71
+
72
+ type PendingReview = { callId: string; url: string };
73
+ type PendingBySession = Record<string, PendingReview>;
74
+
75
+ type ComposerClaim = {
76
+ readonly prepend: "session.composer.top";
77
+ // unknown fits the host's JSX.Element only while @opentui/solid is uninstalled (it is any
78
+ // then); once installed, type this from the host's SlotClaim.
79
+ readonly render: (input: { readonly sessionID: string }) => unknown;
80
+ };
81
+
82
+ /** v2's `Route` as caret reads it: only a session route carries `sessionID`. */
83
+ type Route = { type: string; sessionID?: string };
84
+
85
+ /** caret's one keymap command: the slice of v2's `KeymapCommand` it sets. */
86
+ type CaretCommand = {
87
+ id: string;
88
+ title: string;
89
+ description: string;
90
+ palette: true;
91
+ slash: { name: string };
92
+ bind: string;
93
+ suggested: () => boolean;
94
+ run: () => void;
95
+ };
96
+
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
+ /** The `@opentui/solid` reconciler calls the pending row takes. */
113
+ type OpenTuiReconciler = {
114
+ createElement: (tag: "box" | "text" | "span" | "b") => unknown;
115
+ insertNode: (parent: unknown, child: unknown) => void;
116
+ insert: (parent: unknown, value: string) => unknown;
117
+ setProp: <T>(node: unknown, name: string, value: T, prev?: T) => T;
118
+ effect: <T>(fn: (prev?: T) => T) => void;
31
119
  };
32
120
 
33
- /** Build caret's v2 TUI `setup` over an injected update check. */
121
+ const PENDING_LABEL = "plan waiting for your review";
122
+ const PENDING_ACTION = "open in browser";
123
+
124
+ const plainMemory = (): readonly [
125
+ PendingBySession,
126
+ (mutation: (draft: PendingBySession) => void) => void,
127
+ ] => {
128
+ const pending: PendingBySession = {};
129
+ return [pending, (mutate) => mutate(pending)];
130
+ };
131
+
132
+ /** caret's v2 TUI setup over injected effects; its cleanup releases the slot claim (which
133
+ * lands asynchronously) and every subscription. */
34
134
  export function createCaretTui(opts: {
35
135
  checkUpdate: (show: ToastSink) => void;
136
+ bin: string;
137
+ spawn: DetachedSpawn;
138
+ loadOpenTui: () => Promise<OpenTuiReconciler>;
36
139
  }): (ctx: TuiContext) => () => void {
37
140
  return (ctx) => {
38
141
  const show: ToastSink = (body) => ctx.ui.toast.show(body);
142
+ const openPlan = (sessionID: string | undefined) => {
143
+ const args =
144
+ sessionID === undefined
145
+ ? ["band-link", "--open"]
146
+ : ["band-link", "--session", sessionID, "--open"];
147
+ opts.spawn(opts.bin, args, (err) =>
148
+ toastBestEffort(show, {
149
+ title: "caret: open failed",
150
+ message: `${err.message}. Run caret doctor to see why.`,
151
+ variant: "error",
152
+ }),
153
+ );
154
+ };
155
+ // Tool events carry the call id, not the tool name: a session's entry names the one call
156
+ // whose link it showed, and a later link in that session replaces it.
157
+ const storage = ctx.storage;
158
+ const hasReactiveStore = typeof storage?.memory === "function";
159
+ const [pending, setPending] =
160
+ typeof storage?.memory === "function"
161
+ ? storage.memory("caret.pending", { initial: {} })
162
+ : plainMemory();
163
+ const routedSession = () => ctx.ui.router?.current().sessionID;
164
+ if (typeof ctx.keymap?.layer === "function") {
165
+ try {
166
+ ctx.keymap.layer(() => ({
167
+ // The palette and slash completion list only commands reachable from their own input mode.
168
+ mode: "global",
169
+ commands: [
170
+ {
171
+ id: "caret.open",
172
+ title: "Open caret",
173
+ description: "Open this session's waiting plan in the browser, or caret's home page",
174
+ palette: true,
175
+ slash: { name: "caret" },
176
+ bind: "<leader>p",
177
+ suggested: () => pending[routedSession() ?? ""] !== undefined,
178
+ run: () => openPlan(routedSession()),
179
+ },
180
+ ],
181
+ }));
182
+ } catch {
183
+ // best-effort: a host that rejects the command keeps caret's toasts
184
+ }
185
+ }
186
+ const showIn =
187
+ (sessionID: string): ToastSink =>
188
+ (body) =>
189
+ ctx.ui.toast.show({ ...body, sessionID });
39
190
  try {
40
191
  opts.checkUpdate(show);
41
192
  } catch {
42
193
  // best-effort
43
194
  }
44
195
 
45
- // Tool events carry the call id, not the tool name: only calls that showed a link count.
46
- const callsWithLink = new Set<string>();
47
- const settle = (id: string, outcome: ReviewOutcome) => {
48
- if (callsWithLink.delete(id)) toastBestEffort(show, decisionToast(outcome));
196
+ const settle = (id: string, sessionID: string, outcome: ReviewOutcome) => {
197
+ if (pending[sessionID]?.callId !== id) return;
198
+ setPending((draft) => {
199
+ delete draft[sessionID];
200
+ });
201
+ toastBestEffort(showIn(sessionID), decisionToast(outcome));
49
202
  };
50
203
  const unsubscribers = [
51
204
  ctx.data.on("session.tool.progress", ({ data }) => {
52
205
  const url = data.metadata?.[CARET_URL_KEY];
53
206
  if (typeof url !== "string") return;
54
- callsWithLink.add(data.id);
55
- toastBestEffort(show, reviewLinkToast(url));
207
+ setPending((draft) => {
208
+ draft[data.sessionID] = { callId: data.id, url };
209
+ });
210
+ toastBestEffort(showIn(data.sessionID), reviewLinkToast(url));
56
211
  }),
57
212
  ctx.data.on("session.tool.success", ({ data }) => {
58
213
  const decision = data.metadata?.[CARET_DECISION_KEY];
59
- settle(data.id, decision === "allow" || decision === "deny" ? decision : "cancelled");
214
+ settle(
215
+ data.id,
216
+ data.sessionID,
217
+ decision === "allow" || decision === "deny" ? decision : "cancelled",
218
+ );
60
219
  }),
61
- ctx.data.on("session.tool.failed", ({ data }) => settle(data.id, "cancelled")),
220
+ ctx.data.on("session.tool.failed", ({ data }) =>
221
+ settle(data.id, data.sessionID, "cancelled"),
222
+ ),
62
223
  ];
224
+
225
+ let releaseSlot: (() => void) | undefined;
226
+ let disposed = false;
227
+ // Without the host's reactive store the line could never update.
228
+ if (typeof ctx.ui.slot === "function" && hasReactiveStore) {
229
+ opts
230
+ .loadOpenTui()
231
+ .then((openTui) => {
232
+ if (disposed) return;
233
+ releaseSlot = ctx.ui.slot?.({
234
+ prepend: "session.composer.top",
235
+ render: (input) =>
236
+ pendingRow(openTui, {
237
+ // Read live: ctx.theme is a getter that follows theme switches.
238
+ theme: () => ctx.theme,
239
+ openKey: ctx.keymap?.shortcuts?.("caret.open")[0] ?? "/caret",
240
+ // input is reactive: read sessionID at each call, never destructure it.
241
+ isPending: () => pending[input.sessionID] !== undefined,
242
+ open: () => {
243
+ // As the host's own rows do: a mouse-up that ends a text selection is a copy.
244
+ if (ctx.renderer?.getSelection?.()?.getSelectedText()) return;
245
+ openPlan(input.sessionID);
246
+ },
247
+ }),
248
+ });
249
+ })
250
+ // best-effort: a host whose runtime can't resolve @opentui/solid shows no line
251
+ .catch(() => {});
252
+ }
63
253
  return () => {
254
+ disposed = true;
255
+ releaseSlot?.();
64
256
  for (const unsubscribe of unsubscribers) unsubscribe();
65
257
  };
66
258
  };
67
259
  }
68
260
 
69
- const setup = createCaretTui({ checkUpdate: productionUpdateCheck });
261
+ /** The row above a waiting session's composer: the host's queued-card idiom, one row tall,
262
+ * opening the review on click. */
263
+ function pendingRow(
264
+ openTui: OpenTuiReconciler,
265
+ {
266
+ theme,
267
+ openKey,
268
+ isPending,
269
+ open,
270
+ }: {
271
+ theme: () => TuiTheme | undefined;
272
+ openKey: string;
273
+ isPending: () => boolean;
274
+ open: () => void;
275
+ },
276
+ ): unknown {
277
+ const paint = (node: unknown, name: string, color: (theme: TuiTheme) => unknown) =>
278
+ openTui.effect((prev) => {
279
+ const t = theme();
280
+ return openTui.setProp(node, name, t && color(t), prev);
281
+ });
282
+ const styled = (
283
+ tag: "span" | "b",
284
+ text: string,
285
+ color: (theme: TuiTheme) => unknown,
286
+ ): unknown => {
287
+ const node = openTui.createElement(tag);
288
+ openTui.insert(node, text);
289
+ openTui.effect((prev) => {
290
+ const t = theme();
291
+ return openTui.setProp(node, "style", t ? { fg: color(t) } : {}, prev);
292
+ });
293
+ return node;
294
+ };
295
+ const props = (node: unknown, values: Record<string, unknown>) => {
296
+ for (const [name, value] of Object.entries(values)) openTui.setProp(node, name, value);
297
+ };
298
+ const append = (parent: unknown, ...children: unknown[]) => {
299
+ for (const child of children) openTui.insertNode(parent, child);
300
+ };
301
+
302
+ // Two boxes, as the host's queued card: the rail cell stays on the base background.
303
+ const row = openTui.createElement("box");
304
+ props(row, { border: ["left"], borderStyle: "heavy", marginBottom: 1, onMouseUp: open });
305
+ paint(row, "borderColor", (t) => t.border.base);
306
+ openTui.effect((prev) => openTui.setProp(row, "visible", isPending(), prev));
307
+
308
+ const card = openTui.createElement("box");
309
+ props(card, { flexDirection: "row", flexGrow: 1, paddingLeft: 2, paddingRight: 1 });
310
+ paint(card, "backgroundColor", (t) => t.background.raised.base);
311
+
312
+ const label = openTui.createElement("text");
313
+ props(label, { flexGrow: 1, flexShrink: 1, minWidth: 0, wrapMode: "none", truncate: true });
314
+ append(
315
+ label,
316
+ styled("b", "^", (t) => t.text.feedback.warning.base),
317
+ styled("span", "caret ", (t) => t.text.muted),
318
+ styled("b", PENDING_LABEL, (t) => t.text.base),
319
+ );
320
+
321
+ const hint = openTui.createElement("text");
322
+ props(hint, { flexShrink: 0, wrapMode: "none" });
323
+ append(
324
+ hint,
325
+ styled("span", openKey, (t) => t.text.base),
326
+ styled("span", ` ${PENDING_ACTION}`, (t) => t.text.muted),
327
+ );
328
+
329
+ append(card, label, hint);
330
+ append(row, card);
331
+ return row;
332
+ }
333
+
334
+ // The v2 host rewrites only a bare string-literal specifier here; a cast or a variable sends
335
+ // the import to disk, where nothing installs @opentui/solid.
336
+ const loadOpenTui = (): Promise<OpenTuiReconciler> => import("@opentui/solid");
337
+ const setup = createCaretTui({
338
+ checkUpdate: productionUpdateCheck,
339
+ bin: resolveCaretBin({ env: process.env, importMetaUrl: import.meta.url }),
340
+ spawn: nodeDetachedSpawn,
341
+ loadOpenTui,
342
+ });
70
343
 
71
344
  export default {
72
345
  id: "caret",
@@ -0,0 +1,8 @@
1
+ // The OpenCode v2 TUI host provides this module at runtime; nothing installs it.
2
+ declare module "@opentui/solid" {
3
+ function createElement(tag: "box" | "text" | "span" | "b"): unknown;
4
+ function insertNode(parent: unknown, child: unknown): void;
5
+ function insert(parent: unknown, value: string): unknown;
6
+ function setProp<T>(node: unknown, name: string, value: T, prev?: T): T;
7
+ function effect<T>(fn: (prev?: T) => T): void;
8
+ }
@@ -1,8 +1,6 @@
1
- // OpenCode v2 permission evaluation, for checking a plan file write-back on a v2 before
2
- // 2.0.29, which gives a plugin no way to ask about it (2.0.29+ asks through
3
- // `permission.assert`). Copied from anomalyco/opencode at v2.0.18:
4
- // packages/core/src/util/wildcard.ts (match), packages/core/src/permission.ts (evaluate),
5
- // the resource forming in packages/core/src/file-access.ts (resolve), and
1
+ // The resources OpenCode v2 checks before its own tools edit a file, so caret can
2
+ // `permission.assert` the same ones before it rewrites a plan file. The resource forming
3
+ // mirrors anomalyco/opencode at v2.0.18: packages/core/src/file-access.ts (resolve) and
6
4
  // packages/util/src/fs-util.ts (contains, from FSUtil.contains).
7
5
  //
8
6
  // Copyright (c) 2025 opencode. MIT License. Permission is hereby granted, free of charge,
@@ -31,32 +29,6 @@ export type Check = { action: string; resource: string };
31
29
 
32
30
  export type Location = { directory: string; projectDirectory: string };
33
31
 
34
- /** OpenCode's wildcard match: `*` crosses `/`, and a trailing `" *"` also matches the
35
- * bare prefix. */
36
- export function wildcardMatch(input: string, pattern: string): boolean {
37
- const normalized = input.replaceAll("\\", "/");
38
- let escaped = pattern
39
- .replaceAll("\\", "/")
40
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
41
- .replace(/\*/g, ".*")
42
- .replace(/\?/g, ".");
43
-
44
- if (escaped.endsWith(" .*")) escaped = `${escaped.slice(0, -3)}( .*)?`;
45
-
46
- return new RegExp(`^${escaped}$`, process.platform === "win32" ? "si" : "s").test(normalized);
47
- }
48
-
49
- /** The last rule matching both `action` and `resource` wins; no match asks. */
50
- export function evaluate(action: string, resource: string, ...rulesets: (readonly Rule[])[]): Rule {
51
- return (
52
- rulesets
53
- .flat()
54
- .findLast(
55
- (rule) => wildcardMatch(action, rule.action) && wildcardMatch(resource, rule.resource),
56
- ) ?? { action, resource: "*", effect: "ask" }
57
- );
58
- }
59
-
60
32
  const slash = (value: string) => value.replaceAll("\\", "/");
61
33
 
62
34
  function contains(parent: string, child: string): boolean {
@@ -88,16 +60,3 @@ export function editChecks(planFilePath: string, location: Location): Check[] {
88
60
  { action: "external_directory", resource: slash(path.join(directory, "*")) },
89
61
  ];
90
62
  }
91
-
92
- /** Whether every check OpenCode would run for editing `planFilePath` is `allow`. Session
93
- * rules outrank agent rules. */
94
- export function editPermitted(
95
- planFilePath: string,
96
- location: Location,
97
- rules: { agent: readonly Rule[]; session: readonly Rule[] },
98
- ): boolean {
99
- return editChecks(planFilePath, location).every(
100
- (check) =>
101
- evaluate(check.action, check.resource, rules.agent, rules.session).effect === "allow",
102
- );
103
- }
@@ -9,7 +9,7 @@
9
9
  import { spawn } from "node:child_process";
10
10
 
11
11
  export type CaretDecision =
12
- | { behavior: "allow"; feedback?: string }
12
+ | { behavior: "allow"; feedback?: string; acceptMode?: string }
13
13
  | { behavior: "deny"; feedback: string };
14
14
 
15
15
  /** Asks the model to open its plan on a `#` heading, which deriveTitle
@@ -23,12 +23,18 @@ export const PLAN_TITLE_INSTRUCTION =
23
23
  * reads — both ends are caret-owned. */
24
24
  export function buildEnvelope(
25
25
  plan: string,
26
- ctx: { sessionID?: string; directory?: string; planFilePath?: string },
26
+ ctx: {
27
+ sessionID?: string;
28
+ directory?: string;
29
+ planFilePath?: string;
30
+ approveVariants?: readonly string[];
31
+ },
27
32
  ): string {
28
33
  return JSON.stringify({
29
34
  session_id: ctx.sessionID,
30
35
  cwd: ctx.directory,
31
36
  tool_input: { plan, planFilePath: ctx.planFilePath },
37
+ approve_variants: ctx.approveVariants,
32
38
  });
33
39
  }
34
40
 
@@ -44,11 +50,20 @@ export function parseDecision(stdout: string): CaretDecision {
44
50
  .filter(Boolean)
45
51
  .at(-1) ?? "";
46
52
  try {
47
- const parsed = JSON.parse(line) as { behavior?: unknown; feedback?: unknown };
53
+ const parsed = JSON.parse(line) as {
54
+ behavior?: unknown;
55
+ feedback?: unknown;
56
+ acceptMode?: unknown;
57
+ };
48
58
  if (parsed.behavior === "allow") {
49
59
  // Reviewer notes (EXC-791) ride the allow; surface them to the agent below.
50
60
  const notes = typeof parsed.feedback === "string" ? parsed.feedback.trim() : "";
51
- return notes ? { behavior: "allow", feedback: notes } : { behavior: "allow" };
61
+ const acceptMode = typeof parsed.acceptMode === "string" ? parsed.acceptMode : "";
62
+ return {
63
+ behavior: "allow",
64
+ ...(notes && { feedback: notes }),
65
+ ...(acceptMode && { acceptMode }),
66
+ };
52
67
  }
53
68
  if (parsed.behavior === "deny") {
54
69
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@macintacos/caret",
3
- "version": "1.7.0",
3
+ "version": "1.8.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.",
@@ -54,7 +54,7 @@
54
54
  "@laststance/tailwind-suggest-canonical-classes": "^0.1.1",
55
55
  "@lezer/highlight": "^1.2.3",
56
56
  "@modelcontextprotocol/sdk": "^1.30.0",
57
- "@opencode/plugin": "^2.0.18",
57
+ "@opencode/plugin": "^2.0.29",
58
58
  "@pierre/diffs": "1.5.2",
59
59
  "@pierre/trees": "1.0.0-beta.6",
60
60
  "@playwright/test": "^1.62.1",