@macintacos/caret 1.7.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -7
- package/dist/cli.js +849 -655
- package/opencode/caret.core.ts +14 -6
- package/opencode/caret.plugin.v2.ts +41 -15
- package/opencode/caret.tui.ts +290 -17
- package/opencode/opentui-solid.d.ts +8 -0
- package/opencode/permission.ts +3 -44
- package/opencode/review-bridge.ts +19 -4
- package/package.json +2 -2
- package/ui/dist/assets/{angular-html-C5q3--yq.js → angular-html-BXFKQGXA.js} +1 -1
- package/ui/dist/assets/{angular-ts-BZMKoLwh.js → angular-ts-BteBaZbv.js} +1 -1
- package/ui/dist/assets/{apl-u_Y-hlKV.js → apl-Boa3o4y5.js} +1 -1
- package/ui/dist/assets/{astro-BcDNk4mM.js → astro-BeBp_x10.js} +1 -1
- package/ui/dist/assets/{blade-CaHkf-2N.js → blade-BsWYzT9U.js} +1 -1
- package/ui/dist/assets/{c-Dsz8I3ow.js → c-CiG9bK25.js} +1 -1
- package/ui/dist/assets/{chapel-D5V-mLwt.js → chapel-7fNKKqpl.js} +1 -1
- package/ui/dist/assets/{cobol-CZ1x39wM.js → cobol-B1HBnyA6.js} +1 -1
- package/ui/dist/assets/{coffee-CyYxatBI.js → coffee-BR4Q34UF.js} +1 -1
- package/ui/dist/assets/{cpp-BlgzdeXB.js → cpp-C3wngvr5.js} +1 -1
- package/ui/dist/assets/{crystal-cKocbSAZ.js → crystal-DiIiTPBe.js} +1 -1
- package/ui/dist/assets/{css-D0JtH5v_.js → css-BX3gLifs.js} +1 -1
- package/ui/dist/assets/{dist-Bj2SQgdr.js → dist-50vAtYCv.js} +1 -1
- package/ui/dist/assets/{dist-CsUOEXXq.js → dist-BoVp9dqK.js} +1 -1
- package/ui/dist/assets/{dist-CrA0chnO.js → dist-BqrHiigI.js} +1 -1
- package/ui/dist/assets/{dist-c4FdccU4.js → dist-C79XSdY8.js} +1 -1
- package/ui/dist/assets/{dist-BW8E7IFp.js → dist-CMPsnKBn.js} +2 -2
- package/ui/dist/assets/{dist-BtudxTi6.js → dist-CP875olq.js} +1 -1
- package/ui/dist/assets/{dist-BiNUUFHU.js → dist-D7HvdIC5.js} +1 -1
- package/ui/dist/assets/{dist-C5H8xzaz.js → dist-DXddG2tW.js} +1 -1
- package/ui/dist/assets/{dist-BIlCJN2Y.js → dist-DaLa-fRf.js} +1 -1
- package/ui/dist/assets/{dist-C0kJT6Lc.js → dist-H9wPekMH.js} +1 -1
- package/ui/dist/assets/{dist-BzruqOsf.js → dist-NwnWQZNa.js} +1 -1
- package/ui/dist/assets/{dist-CaG2AYjW.js → dist-SYZxBIS_.js} +1 -1
- package/ui/dist/assets/{dist-BvoAsNKa.js → dist-anXz8tLl.js} +1 -1
- package/ui/dist/assets/{dist-Clc7NktG.js → dist-jDKYW61p.js} +1 -1
- package/ui/dist/assets/{dist-ldnZ9m94.js → dist-p9qfYPbt.js} +1 -1
- package/ui/dist/assets/{dist-CM8mgDfc.js → dist-r7n7YX8s.js} +1 -1
- package/ui/dist/assets/{dist--iE_Uraw.js → dist-sf8u9yOd.js} +1 -1
- package/ui/dist/assets/{dist-8wDQboSp.js → dist-yYI7sEVm.js} +1 -1
- package/ui/dist/assets/{edge-BXjb1Fky.js → edge-D6eM3HKg.js} +1 -1
- package/ui/dist/assets/{elixir-D31Kjwlm.js → elixir-BiXwbjRd.js} +1 -1
- package/ui/dist/assets/{elm-B5mUFALO.js → elm-uPrh5C1H.js} +1 -1
- package/ui/dist/assets/{erb-COidarKq.js → erb-C01CNhsq.js} +1 -1
- package/ui/dist/assets/{git-rebase-CO-FuYxX.js → git-rebase-BctEis_g.js} +1 -1
- package/ui/dist/assets/{glimmer-js-Dw-Aan3t.js → glimmer-js-gguWPKCT.js} +1 -1
- package/ui/dist/assets/{glimmer-ts-Bm5Mn1Vj.js → glimmer-ts-ByonKsDM.js} +1 -1
- package/ui/dist/assets/{glsl-Cu8J8I23.js → glsl-Buh4zHgg.js} +1 -1
- package/ui/dist/assets/{graphql-DRmzT1X9.js → graphql-DtmB7miI.js} +1 -1
- package/ui/dist/assets/{hack-CLlKhpHv.js → hack-qTVQw4Zz.js} +1 -1
- package/ui/dist/assets/{haml-BKV1Cxo8.js → haml-Bt2RhQj7.js} +1 -1
- package/ui/dist/assets/{handlebars-C4E1BT1n.js → handlebars-QJ7q0AEL.js} +1 -1
- package/ui/dist/assets/{html-CHYMYiYm.js → html-Db-D3rXr.js} +1 -1
- package/ui/dist/assets/{html-derivative-Ddt8f4l_.js → html-derivative-DX0FUICr.js} +1 -1
- package/ui/dist/assets/{http-DroO1D8q.js → http-CF8QPtf7.js} +1 -1
- package/ui/dist/assets/hurl-GyiNlLSJ.js +1 -0
- package/ui/dist/assets/index-CfQ31NDJ.js +4303 -0
- package/ui/dist/assets/index-P_fbn8LU.css +1 -0
- package/ui/dist/assets/{java-B83ftZow.js → java-7GW5fJMj.js} +1 -1
- package/ui/dist/assets/{javascript-BKQ7dRer.js → javascript-DLle3-Wd.js} +1 -1
- package/ui/dist/assets/{jinja-pCX35ufE.js → jinja-DyZ1srCs.js} +1 -1
- package/ui/dist/assets/{jison-DKS7kdv3.js → jison-B5gggSJ6.js} +1 -1
- package/ui/dist/assets/{json-DJDjr2Fk.js → json-CQEsMxfE.js} +1 -1
- package/ui/dist/assets/{jsx-BMvVlDf-.js → jsx-BP9hEOr_.js} +1 -1
- package/ui/dist/assets/julia-CIVTqrrA.js +1 -0
- package/ui/dist/assets/{just-Bj4rsdYn.js → just-D4NdkezD.js} +1 -1
- package/ui/dist/assets/{latex-Pfe9R48A.js → latex-CFviGdV1.js} +1 -1
- package/ui/dist/assets/{liquid-DyT_91MF.js → liquid-Dtx11Zuh.js} +1 -1
- package/ui/dist/assets/{lua-3K4juc6q.js → lua-kGMOY_yZ.js} +1 -1
- package/ui/dist/assets/marko-OjkhOEN7.js +1 -0
- package/ui/dist/assets/{mdc-C99mMz9_.js → mdc-wyhjzzvx.js} +1 -1
- package/ui/dist/assets/{nginx-BTww09OP.js → nginx-C6dVmlUS.js} +1 -1
- package/ui/dist/assets/{nim-BzlwRR3n.js → nim-BX63w2ay.js} +1 -1
- package/ui/dist/assets/{org-CljTStQ9.js → org-DZgxDttS.js} +1 -1
- package/ui/dist/assets/{perl-BcVv8945.js → perl-JXbylPLY.js} +1 -1
- package/ui/dist/assets/{php-Cgj2CHLZ.js → php-5EbhLfI_.js} +1 -1
- package/ui/dist/assets/{pug-C2lXyydO.js → pug-C6Iv2U2H.js} +1 -1
- package/ui/dist/assets/{qml-Bl8egcGH.js → qml-CqGs5UjC.js} +1 -1
- package/ui/dist/assets/{r-EJwt6W1C.js → r-BKwdj1Ob.js} +1 -1
- package/ui/dist/assets/{razor-CqdFmSYN.js → razor-CAmmpIRM.js} +1 -1
- package/ui/dist/assets/{regexp-VbabPNwM.js → regexp-D-P27PhL.js} +1 -1
- package/ui/dist/assets/rst-BtBzILBo.js +1 -0
- package/ui/dist/assets/{ruby-D29JuVTu.js → ruby-BFChMOIO.js} +1 -1
- package/ui/dist/assets/{sas-C-AnN273.js → sas-BHer69PY.js} +1 -1
- package/ui/dist/assets/{scss-BlX9mG-h.js → scss-B5xzD0OV.js} +1 -1
- package/ui/dist/assets/{shellscript-CMUNLGjW.js → shellscript-Dzit1Ttv.js} +1 -1
- package/ui/dist/assets/{shellsession-CU92PuJS.js → shellsession-CMvz-BMu.js} +1 -1
- package/ui/dist/assets/{soy-B-tRZ9tr.js → soy-D7ynJc6J.js} +1 -1
- package/ui/dist/assets/{sql-DGtAX9J-.js → sql-bRew6PYM.js} +1 -1
- package/ui/dist/assets/{stata-MlknyQh8.js → stata-22_vRW2L.js} +1 -1
- package/ui/dist/assets/{surrealql-DG9gpvrR.js → surrealql-C34aKiyl.js} +1 -1
- package/ui/dist/assets/{svelte-CRF-aI4U.js → svelte-Dto-0MeJ.js} +1 -1
- package/ui/dist/assets/{templ-D5LmSzZw.js → templ-DtacKQ1n.js} +1 -1
- package/ui/dist/assets/{tex-rLbXYSuW.js → tex-CS5XiOvi.js} +1 -1
- package/ui/dist/assets/{ts-tags-BYnqcC40.js → ts-tags-BdzU5K8A.js} +1 -1
- package/ui/dist/assets/{tsx-DP4HxAmx.js → tsx-Butg554t.js} +1 -1
- package/ui/dist/assets/{twig-CDmnuTZG.js → twig-CZKpRnni.js} +1 -1
- package/ui/dist/assets/{typescript-Ofbi-XAa.js → typescript-CZA7NJZI.js} +1 -1
- package/ui/dist/assets/typst-DIVncoNE.js +1 -0
- package/ui/dist/assets/{vue-AaAqfFpz.js → vue-Bn3_aUS9.js} +1 -1
- package/ui/dist/assets/{vue-html-BzV_zJ1U.js → vue-html-B7r3yzLD.js} +1 -1
- package/ui/dist/assets/{vue-vine-JBbwboUa.js → vue-vine-B3JnxtKQ.js} +1 -1
- package/ui/dist/assets/{xml-Bj-cIM0b.js → xml-DKhH88k1.js} +1 -1
- package/ui/dist/assets/{xsl-vvZR97oN.js → xsl-boEEflBZ.js} +1 -1
- package/ui/dist/assets/{yaml-B-NDgHQf.js → yaml-B2pr6j9H.js} +1 -1
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/hurl-CAWxNLHB.js +0 -1
- package/ui/dist/assets/index-Cyh9CNgl.js +0 -4291
- package/ui/dist/assets/index-rBGdjbxx.css +0 -1
- package/ui/dist/assets/julia-uCo-07PP.js +0 -1
- package/ui/dist/assets/marko-VZPFNtBh.js +0 -1
- package/ui/dist/assets/rst-BgT4O1bg.js +0 -1
- package/ui/dist/assets/typst-DZ1MvNHq.js +0 -1
package/opencode/caret.core.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type SpawnRunner,
|
|
21
21
|
} from "./review-bridge.ts";
|
|
22
22
|
|
|
23
|
-
/** The caret binary the
|
|
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
|
|
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
|
|
410
|
-
|
|
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
|
|
429
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/opencode/caret.tui.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
// caret's OpenCode v2 TUI plugin: the review-link, decision, and update toasts
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
// `tui`
|
|
5
|
-
// v1's
|
|
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 = {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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(
|
|
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 }) =>
|
|
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
|
-
|
|
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
|
+
}
|
package/opencode/permission.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// OpenCode v2
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|