@shanepadgett/tau-agent 0.43.0 → 0.44.1
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/extensions/script-runner/README.md +3 -1
- package/extensions/script-runner/index.ts +62 -125
- package/extensions/tool-approval/README.md +3 -3
- package/extensions/tool-approval/index.ts +153 -27
- package/extensions/tool-approval/panel.ts +16 -2
- package/package.json +2 -2
- package/shared/events.ts +5 -0
- package/shared/script-source.ts +94 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Gives the agent a first-class `script_runner` tool for running Python 3, Node.js, and Deno scripts instead of falling back to bash. The agent picks whichever runtime is more efficient for the task.
|
|
4
4
|
|
|
5
|
-
When a run fails, the tool keeps the script and returns a `scriptId`. The agent retries with targeted `{oldText, newText}` edits against what it just wrote instead of resending the whole script, saving output tokens and keeping duplicate scripts out of context. Only the source the agent already sent is referenced; no file path is exposed.
|
|
5
|
+
When a run fails, the tool keeps the script and returns a `scriptId`. The agent retries with targeted `{oldText, newText}` edits against what it just wrote instead of resending the whole script, saving output tokens and keeping duplicate scripts out of context. Only the source the agent already sent is referenced; no script source file path is exposed.
|
|
6
|
+
|
|
7
|
+
Long output shows the tail and a path to the complete output in a temporary file for the active session.
|
|
6
8
|
|
|
7
9
|
Runtimes are detected from the environment: Python 3 via `python3`, Node via the current process when Node is 22.6 or newer (`node --experimental-strip-types`), Deno via `deno` (`deno run -A`). The `node` language is the local Node.js runtime with full Node APIs; scripts may be TypeScript with erasable syntax or plain JavaScript. The `deno` language is the local Deno runtime with full permissions and native TypeScript/JavaScript. The tool registers only the runtimes actually available and is hidden from the prompt entirely when none are present.
|
|
@@ -4,17 +4,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_MAX_BYTES,
|
|
9
|
-
DEFAULT_MAX_LINES,
|
|
10
|
-
defineTool,
|
|
11
|
-
type ExecResult,
|
|
12
|
-
type ExtensionAPI,
|
|
13
|
-
type Theme,
|
|
14
|
-
truncateTail,
|
|
15
|
-
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { defineTool, type ExecResult, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
16
8
|
import { Text } from "@earendil-works/pi-tui";
|
|
17
9
|
import { Type } from "typebox";
|
|
10
|
+
import { BoundedTextResultBuilder } from "../../shared/bounded-text-result.ts";
|
|
11
|
+
import { onTauEventImmediately } from "../../shared/events.ts";
|
|
12
|
+
import { createScriptSourceStore } from "../../shared/script-source.ts";
|
|
13
|
+
import { createTemporaryOutputStore } from "../../shared/temporary-output-store.ts";
|
|
18
14
|
import { renderToolOutputPreview } from "../../shared/text.ts";
|
|
19
15
|
|
|
20
16
|
type Language = "python3" | "node" | "deno";
|
|
@@ -25,13 +21,7 @@ interface Runtimes {
|
|
|
25
21
|
deno: string | undefined;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
interface StoredScript {
|
|
29
|
-
language: Language;
|
|
30
|
-
source: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
24
|
const TIMEOUT_MS = 120_000;
|
|
34
|
-
const MAX_STORED = 8;
|
|
35
25
|
|
|
36
26
|
function detectRuntimes(): Runtimes {
|
|
37
27
|
let python3: string | undefined;
|
|
@@ -81,18 +71,6 @@ function scrubPath(text: string, file: string, dir: string): string {
|
|
|
81
71
|
return text.replaceAll(file, "<script>").replaceAll(dir, "<tmpdir>");
|
|
82
72
|
}
|
|
83
73
|
|
|
84
|
-
function applyEdits(source: string, edits: ReadonlyArray<{ oldText: string; newText: string }>): string {
|
|
85
|
-
let next = source;
|
|
86
|
-
for (const edit of edits) {
|
|
87
|
-
const idx = next.indexOf(edit.oldText);
|
|
88
|
-
if (idx === -1) {
|
|
89
|
-
throw new Error("edits oldText not found. Copy exact text from the script you wrote.");
|
|
90
|
-
}
|
|
91
|
-
next = next.slice(0, idx) + edit.newText + next.slice(idx + edit.oldText.length);
|
|
92
|
-
}
|
|
93
|
-
return next;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
74
|
function renderEditsPreview(edits: ReadonlyArray<{ oldText: string; newText: string }>, theme: Theme): string {
|
|
97
75
|
return edits
|
|
98
76
|
.map((edit) => {
|
|
@@ -109,56 +87,6 @@ function renderEditsPreview(edits: ReadonlyArray<{ oldText: string; newText: str
|
|
|
109
87
|
.join("\n");
|
|
110
88
|
}
|
|
111
89
|
|
|
112
|
-
function resolveScriptSource(
|
|
113
|
-
scripts: Map<string, StoredScript>,
|
|
114
|
-
language: Language,
|
|
115
|
-
params: {
|
|
116
|
-
script?: string;
|
|
117
|
-
scriptId?: string;
|
|
118
|
-
edits?: ReadonlyArray<{ oldText: string; newText: string }>;
|
|
119
|
-
},
|
|
120
|
-
): { scriptId: string; source: string } {
|
|
121
|
-
const edits = params.edits;
|
|
122
|
-
if (edits && edits.length > 0) {
|
|
123
|
-
const scriptId = params.scriptId;
|
|
124
|
-
if (!scriptId) throw new Error("edits require scriptId from the failed run.");
|
|
125
|
-
const stored = scripts.get(scriptId);
|
|
126
|
-
if (!stored) throw new Error(`No stored script for scriptId ${scriptId}. Evicted; resend full script.`);
|
|
127
|
-
if (stored.language !== language) {
|
|
128
|
-
throw new Error(`Language mismatch: scriptId ${scriptId} is ${stored.language}, not ${language}.`);
|
|
129
|
-
}
|
|
130
|
-
return { scriptId, source: applyEdits(stored.source, edits) };
|
|
131
|
-
}
|
|
132
|
-
if (typeof params.script !== "string" || params.script.length === 0) {
|
|
133
|
-
throw new Error("Provide script, or edits + scriptId.");
|
|
134
|
-
}
|
|
135
|
-
return { scriptId: params.scriptId ?? newScriptId(), source: params.script };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function formatTruncatedTail(text: string): { body: string; note: string } {
|
|
139
|
-
const trunc = truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
140
|
-
const note = trunc.truncated
|
|
141
|
-
? `\n\n[output truncated: kept tail ${trunc.outputLines} / ${trunc.totalLines} lines]`
|
|
142
|
-
: "";
|
|
143
|
-
return { body: trunc.content, note };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function successScriptResult(stdout: string): { content: [{ type: "text"; text: string }]; details: undefined } {
|
|
147
|
-
const { body, note } = formatTruncatedTail(stdout.trim());
|
|
148
|
-
const out = body.trim();
|
|
149
|
-
return {
|
|
150
|
-
content: [{ type: "text", text: out ? `${out}${note}` : `(no output)${note}` }],
|
|
151
|
-
details: undefined,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function throwScriptFailure(scriptId: string, result: { stdout: string; stderr: string }): never {
|
|
156
|
-
const diag = result.stderr.trim() || result.stdout.trim();
|
|
157
|
-
const { body, note } = formatTruncatedTail(diag);
|
|
158
|
-
const detail = body ? `${body}${note}\n\n` : "";
|
|
159
|
-
throw new Error(`${detail}scriptId: ${scriptId}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
90
|
export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
163
91
|
const runtimes = detectRuntimes();
|
|
164
92
|
const detected = (["python3", "node", "deno"] as const).filter(
|
|
@@ -168,17 +96,15 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
168
96
|
|
|
169
97
|
const langPhrase = formatLangList(detected);
|
|
170
98
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
}
|
|
99
|
+
const scriptStore = createScriptSourceStore();
|
|
100
|
+
const temporaryOutput = createTemporaryOutputStore();
|
|
101
|
+
onTauEventImmediately(pi, "script-runner.source-store", "tau:script-runner.source-store", ({ accept }) =>
|
|
102
|
+
accept(scriptStore),
|
|
103
|
+
);
|
|
104
|
+
pi.on("session_start", async () => {
|
|
105
|
+
await temporaryOutput.shutdown();
|
|
106
|
+
await temporaryOutput.start();
|
|
107
|
+
});
|
|
182
108
|
|
|
183
109
|
function resolveCommand(language: Language): string {
|
|
184
110
|
const cmd = runtimes[language];
|
|
@@ -190,12 +116,6 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
190
116
|
throw new Error("Deno is not available on this machine.");
|
|
191
117
|
}
|
|
192
118
|
|
|
193
|
-
async function ensureTempDir(): Promise<string> {
|
|
194
|
-
if (tempDir) return tempDir;
|
|
195
|
-
tempDir = await mkdtemp(join(tmpdir(), "tau-script-runner-"));
|
|
196
|
-
return tempDir;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
119
|
async function runScript(
|
|
200
120
|
language: Language,
|
|
201
121
|
command: string,
|
|
@@ -203,21 +123,27 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
203
123
|
cwd: string,
|
|
204
124
|
signal: AbortSignal | undefined,
|
|
205
125
|
): Promise<ExecResult> {
|
|
206
|
-
const dir = await
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
126
|
+
const dir = await mkdtemp(join(tmpdir(), "tau-script-runner-"));
|
|
127
|
+
try {
|
|
128
|
+
const file = join(dir, language === "python3" ? "_run.py" : "_run.ts");
|
|
129
|
+
await writeFile(file, source, "utf8");
|
|
130
|
+
const args =
|
|
131
|
+
language === "python3"
|
|
132
|
+
? [file]
|
|
133
|
+
: language === "node"
|
|
134
|
+
? ["--experimental-strip-types", file]
|
|
135
|
+
: ["run", "-A", file];
|
|
136
|
+
const result = await pi.exec(command, args, { cwd, signal, timeout: TIMEOUT_MS });
|
|
137
|
+
return {
|
|
138
|
+
...result,
|
|
139
|
+
stdout: scrubPath(result.stdout, file, dir),
|
|
140
|
+
stderr: scrubPath(result.stderr, file, dir),
|
|
141
|
+
};
|
|
142
|
+
} finally {
|
|
143
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {
|
|
144
|
+
// best-effort cleanup
|
|
145
|
+
});
|
|
146
|
+
}
|
|
221
147
|
}
|
|
222
148
|
|
|
223
149
|
const paramsSchema = Type.Object(
|
|
@@ -266,24 +192,41 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
266
192
|
"script_runner never exposes the script path; you already have the source. Never try to read it back.",
|
|
267
193
|
],
|
|
268
194
|
parameters: paramsSchema,
|
|
269
|
-
async execute(
|
|
195
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
270
196
|
const language = params.language;
|
|
271
197
|
if (signal?.aborted) {
|
|
272
198
|
return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
|
|
273
199
|
}
|
|
200
|
+
scriptStore.verifyAndConsume(toolCallId, params);
|
|
274
201
|
const command = resolveCommand(language);
|
|
275
|
-
const
|
|
276
|
-
|
|
202
|
+
const resolved = scriptStore.resolve(params);
|
|
203
|
+
const scriptId = resolved.scriptId ?? newScriptId();
|
|
204
|
+
const source = resolved.source;
|
|
205
|
+
scriptStore.remember(scriptId, language, source);
|
|
277
206
|
await onUpdate?.({
|
|
278
207
|
content: [{ type: "text", text: `Running ${languageLabel(language)}...` }],
|
|
279
208
|
details: undefined,
|
|
280
209
|
});
|
|
281
210
|
const result = await runScript(language, command, source, ctx.cwd, signal);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
211
|
+
const succeeded = result.code === 0 && !result.killed;
|
|
212
|
+
const output = new BoundedTextResultBuilder(temporaryOutput, "tail");
|
|
213
|
+
let content: string;
|
|
214
|
+
try {
|
|
215
|
+
await output.append(succeeded ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim());
|
|
216
|
+
if (signal?.aborted) {
|
|
217
|
+
await output.abort();
|
|
218
|
+
return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
|
|
219
|
+
}
|
|
220
|
+
content = (await output.finish()).content;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await output.abort();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
if (succeeded) {
|
|
226
|
+
scriptStore.forget(scriptId);
|
|
227
|
+
return { content: [{ type: "text", text: content.trim() || "(no output)" }], details: undefined };
|
|
285
228
|
}
|
|
286
|
-
|
|
229
|
+
throw new Error(`${content ? `${content}\n\n` : ""}scriptId: ${scriptId}`);
|
|
287
230
|
},
|
|
288
231
|
renderCall(args, theme, context) {
|
|
289
232
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
@@ -313,14 +256,8 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
313
256
|
|
|
314
257
|
pi.registerTool(tool);
|
|
315
258
|
|
|
316
|
-
pi.on("session_shutdown", () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
tempDir = undefined;
|
|
320
|
-
if (dir) {
|
|
321
|
-
void rm(dir, { recursive: true, force: true }).catch(() => {
|
|
322
|
-
// best-effort cleanup
|
|
323
|
-
});
|
|
324
|
-
}
|
|
259
|
+
pi.on("session_shutdown", async () => {
|
|
260
|
+
scriptStore.clear();
|
|
261
|
+
await temporaryOutput.shutdown();
|
|
325
262
|
});
|
|
326
263
|
}
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Reviews agent `bash` and `script_runner` requests before they run.
|
|
4
4
|
|
|
5
|
-
Common read-only bash commands skip review and run immediately. Other bash and every `script_runner` request go to a separate reviewer. Tau uses the reviewer model for the current provider, then the current chat model if that reviewer is unavailable or fails. If a reviewer model is unavailable or fails, Tau notifies and tries the next one. The reviewer returns a validated decision and one concise paragraph that explains the request.
|
|
5
|
+
Common read-only bash commands skip review and run immediately. Other bash and every `script_runner` request go to a separate reviewer. For a `script_runner` retry with `scriptId` and edits, Tau reconstructs the full resulting script before review and runs that exact source after approval. A retry with missing or invalid stored source is blocked. When the agent requests several tools at once, Tau reviews up to three requests concurrently, with one model review focused on each request. Tau uses the reviewer model for the current provider, then the current chat model if that reviewer is unavailable or fails. If a reviewer model is unavailable or fails, Tau notifies and tries the next one. The reviewer returns a validated decision and one concise paragraph that explains the request.
|
|
6
6
|
|
|
7
7
|
With `autoApprove` enabled, reviewer-approved requests run without another confirmation. Tau shows a user-only marker with the reviewer model after those auto-approvals. Common read-only bash that skips review does not get a marker. Routine local development work should be approved, including requests that modify project files or run scripts. The reviewer asks for human approval only when it finds a concrete destructive, system, production, privileged, or security-sensitive effect.
|
|
8
8
|
|
|
9
|
-
When approval is required, Tau shows one paragraph that explains the effect and risk without repeating the request. If the reviewer fails or returns a malformed decision, Tau asks for direct human approval instead of running it automatically. Tau also sends an attention notification when the approval window opens.
|
|
9
|
+
When approval is required, Tau shows one paragraph that explains the effect and risk without repeating the request. For `script_runner`, human confirmation also shows the complete script that will run. If several requests need approval, their confirmation windows open one at a time. If the reviewer fails or returns a malformed decision, Tau asks for direct human approval instead of running it automatically. Tau also sends an attention notification when the approval window opens.
|
|
10
10
|
|
|
11
|
-
In the terminal approval panel, move between Approve and Reject, press `n` to add a note to the highlighted choice, then press Enter to choose. Enter saves an edited note before choosing; Escape cancels note editing or blocks the request from the choice list. A rejection note tells the agent why the request was blocked. An approval note reaches the agent with the tool result; it does not change the request being approved. To ask for a different request, reject it with a note. Long notes are truncated. RPC clients use the standard confirmation dialog without notes.
|
|
11
|
+
In the terminal approval panel, move between Approve and Reject, press `j` or `k` to scroll a script, press `n` to add a note to the highlighted choice, then press Enter to choose. Enter saves an edited note before choosing; Escape cancels note editing or blocks the request from the choice list. A rejection note tells the agent why the request was blocked. An approval note reaches the agent with the tool result; it does not change the request being approved. To ask for a different request, reject it with a note. Long notes are truncated. RPC clients use the standard confirmation dialog without notes.
|
|
12
12
|
|
|
13
13
|
Configure under `extensions.toolApproval`:
|
|
14
14
|
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
import { Marker } from "@shanepadgett/tau-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
|
|
11
|
+
import { emitTauEvent } from "../../shared/events.ts";
|
|
11
12
|
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
13
|
+
import type { ScriptSourceStore } from "../../shared/script-source.ts";
|
|
12
14
|
import { errorText, truncAt } from "../../shared/text.ts";
|
|
13
15
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
14
16
|
import { isAllowlistedBash } from "./allowlist.ts";
|
|
@@ -17,6 +19,7 @@ import toolApprovalSettings from "./settings.ts";
|
|
|
17
19
|
|
|
18
20
|
const STATUS_KEY = "tool-approval";
|
|
19
21
|
const AUTO_APPROVED_TYPE = "tau.tool-approval.auto-approved";
|
|
22
|
+
const MAX_CONCURRENT_REVIEWS = 3;
|
|
20
23
|
|
|
21
24
|
const REVIEW_SCHEMA = Type.Object(
|
|
22
25
|
{
|
|
@@ -75,6 +78,13 @@ interface ToolApprovalRequest {
|
|
|
75
78
|
input: Record<string, unknown>;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
type ToolReviewResult = { review: ToolReview; provider: string; model: string };
|
|
82
|
+
|
|
83
|
+
interface CachedReview {
|
|
84
|
+
requestJson: string;
|
|
85
|
+
outcome: PromiseSettledResult<ToolReviewResult>;
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
interface AutoApprovedMarker {
|
|
79
89
|
toolName: ApprovalToolName;
|
|
80
90
|
provider: string;
|
|
@@ -83,6 +93,7 @@ interface AutoApprovedMarker {
|
|
|
83
93
|
|
|
84
94
|
export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
85
95
|
let settings = toolApprovalSettings.defaults;
|
|
96
|
+
let batchReviews: Map<string, CachedReview> | undefined;
|
|
86
97
|
const pendingNotes = new Map<string, string>();
|
|
87
98
|
|
|
88
99
|
pi.registerEntryRenderer<AutoApprovedMarker>(AUTO_APPROVED_TYPE, (entry, _options, theme) => {
|
|
@@ -103,10 +114,16 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
103
114
|
async function requestToolApproval(
|
|
104
115
|
ctx: ExtensionContext,
|
|
105
116
|
toolCallId: string,
|
|
106
|
-
|
|
117
|
+
request: ToolApprovalRequest,
|
|
107
118
|
title: string,
|
|
108
119
|
body: string,
|
|
109
120
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
121
|
+
const toolName = request.toolName;
|
|
122
|
+
const source =
|
|
123
|
+
toolName === "script_runner" && typeof request.input.script === "string" ? request.input.script : undefined;
|
|
124
|
+
if (toolName === "script_runner" && source === undefined) {
|
|
125
|
+
return block("script_runner source is unavailable for manual approval");
|
|
126
|
+
}
|
|
110
127
|
if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
|
|
111
128
|
try {
|
|
112
129
|
emitAgentBlocked(pi, {
|
|
@@ -115,11 +132,14 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
115
132
|
source: "tool-approval.review",
|
|
116
133
|
});
|
|
117
134
|
if (ctx.mode !== "tui") {
|
|
118
|
-
const confirmed = await ctx.ui.confirm(
|
|
135
|
+
const confirmed = await ctx.ui.confirm(
|
|
136
|
+
title,
|
|
137
|
+
source === undefined ? body : `${body}\n\nFull script:\n${source}`,
|
|
138
|
+
);
|
|
119
139
|
return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
|
|
120
140
|
}
|
|
121
141
|
const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
|
|
122
|
-
(tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, done),
|
|
142
|
+
(tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, source, done),
|
|
123
143
|
);
|
|
124
144
|
if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
|
|
125
145
|
const note = truncAt(answer.note, 800);
|
|
@@ -136,7 +156,9 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
pi.on("session_start", async (_event, ctx) => {
|
|
159
|
+
batchReviews = undefined;
|
|
139
160
|
pendingNotes.clear();
|
|
161
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
140
162
|
await refreshSettings(ctx);
|
|
141
163
|
});
|
|
142
164
|
|
|
@@ -151,61 +173,98 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
151
173
|
return block(`tool approval settings failed to load: ${truncAt(message, 600)}`);
|
|
152
174
|
}
|
|
153
175
|
if (!settings.enabled) return undefined;
|
|
176
|
+
const scriptStore = scriptSourceStoreFrom(pi);
|
|
177
|
+
if (request.toolName === "script_runner") {
|
|
178
|
+
try {
|
|
179
|
+
if (!scriptStore) return block("script_runner source store is unavailable for review");
|
|
180
|
+
const { source } = scriptStore.resolve(request.input);
|
|
181
|
+
request.input.script = source;
|
|
182
|
+
delete request.input.edits;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return block(`script_runner source could not be reviewed: ${errorText(error)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
154
187
|
|
|
155
|
-
let command: string | undefined;
|
|
156
188
|
if (request.toolName === "bash") {
|
|
157
|
-
const
|
|
158
|
-
if (typeof
|
|
159
|
-
if (!
|
|
189
|
+
const command = request.input.command;
|
|
190
|
+
if (typeof command !== "string") return block("bash command was malformed");
|
|
191
|
+
if (!command.trim()) {
|
|
160
192
|
ctx.ui.notify("Bash command blocked: command is empty", "warning");
|
|
161
193
|
return block("bash command is empty");
|
|
162
194
|
}
|
|
163
|
-
command = value;
|
|
164
195
|
if (isAllowlistedBash(command)) return undefined;
|
|
165
196
|
}
|
|
166
197
|
|
|
167
198
|
ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
|
|
168
199
|
try {
|
|
169
|
-
|
|
200
|
+
batchReviews ??= await reviewAssistantRequests(ctx, event, request, scriptStore);
|
|
201
|
+
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
202
|
+
const cached = batchReviews.get(event.toolCallId);
|
|
203
|
+
batchReviews.delete(event.toolCallId);
|
|
204
|
+
let result: ToolReviewResult;
|
|
205
|
+
if (cached && cached.requestJson === JSON.stringify(request)) {
|
|
206
|
+
if (cached.outcome.status === "rejected") throw cached.outcome.reason;
|
|
207
|
+
result = cached.outcome.value;
|
|
208
|
+
} else {
|
|
209
|
+
// A sibling's validated input may differ from its arguments in the assistant message.
|
|
210
|
+
result = await reviewToolRequest(ctx, request);
|
|
211
|
+
}
|
|
212
|
+
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
213
|
+
const { review, provider, model } = result;
|
|
214
|
+
let rejected: { block: true; reason: string } | undefined;
|
|
170
215
|
if (review.decision === "requires_user_approval") {
|
|
171
|
-
|
|
216
|
+
rejected = await requestToolApproval(
|
|
172
217
|
ctx,
|
|
173
218
|
event.toolCallId,
|
|
174
|
-
request
|
|
219
|
+
request,
|
|
175
220
|
`Approve high-impact ${toolLabel(request.toolName)}?`,
|
|
176
221
|
formatApproval(review.summary, review.reason),
|
|
177
222
|
);
|
|
178
|
-
}
|
|
179
|
-
if (settings.autoApprove) {
|
|
223
|
+
} else if (settings.autoApprove) {
|
|
180
224
|
pi.appendEntry<AutoApprovedMarker>(AUTO_APPROVED_TYPE, {
|
|
181
225
|
toolName: request.toolName,
|
|
182
226
|
provider,
|
|
183
227
|
model,
|
|
184
228
|
});
|
|
185
|
-
|
|
229
|
+
} else {
|
|
230
|
+
rejected = await requestToolApproval(
|
|
231
|
+
ctx,
|
|
232
|
+
event.toolCallId,
|
|
233
|
+
request,
|
|
234
|
+
`Run reviewed ${toolLabel(request.toolName)}?`,
|
|
235
|
+
formatApproval(review.summary, "Automatic approval is disabled."),
|
|
236
|
+
);
|
|
186
237
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
event.toolCallId,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
formatApproval(review.summary, "Automatic approval is disabled."),
|
|
193
|
-
);
|
|
238
|
+
if (!rejected && request.toolName === "script_runner") {
|
|
239
|
+
if (!scriptStore) return block("script_runner source store is unavailable for approval");
|
|
240
|
+
scriptStore.approve(event.toolCallId, request.input);
|
|
241
|
+
}
|
|
242
|
+
return rejected;
|
|
194
243
|
} catch (error) {
|
|
244
|
+
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
195
245
|
const message = singleLine(errorText(error));
|
|
196
246
|
ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
|
|
197
|
-
|
|
247
|
+
const rejected = await requestToolApproval(
|
|
198
248
|
ctx,
|
|
199
249
|
event.toolCallId,
|
|
200
|
-
request
|
|
250
|
+
request,
|
|
201
251
|
`Automatic ${toolLabel(request.toolName)} review failed. Continue?`,
|
|
202
|
-
`The automatic review failed, so Tau could not summarize this ${toolLabel(request.toolName)}. Approve it only if you understand the request shown above.`,
|
|
252
|
+
`The automatic review failed, so Tau could not summarize this ${toolLabel(request.toolName)}. Approve it only if you understand ${request.toolName === "script_runner" ? "the full script below" : "the request shown above"}.`,
|
|
203
253
|
);
|
|
254
|
+
if (!rejected && request.toolName === "script_runner") {
|
|
255
|
+
if (!scriptStore) return block("script_runner source store is unavailable for approval");
|
|
256
|
+
scriptStore.approve(event.toolCallId, request.input);
|
|
257
|
+
}
|
|
258
|
+
return rejected;
|
|
204
259
|
} finally {
|
|
205
260
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
206
261
|
}
|
|
207
262
|
});
|
|
208
263
|
|
|
264
|
+
pi.on("turn_end", () => {
|
|
265
|
+
batchReviews = undefined;
|
|
266
|
+
});
|
|
267
|
+
|
|
209
268
|
pi.on("tool_result", (event) => {
|
|
210
269
|
const note = pendingNotes.get(event.toolCallId);
|
|
211
270
|
if (!note) return;
|
|
@@ -222,15 +281,31 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
222
281
|
});
|
|
223
282
|
|
|
224
283
|
pi.on("agent_end", () => {
|
|
284
|
+
batchReviews = undefined;
|
|
225
285
|
pendingNotes.clear();
|
|
286
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
226
287
|
});
|
|
227
288
|
|
|
228
289
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
290
|
+
batchReviews = undefined;
|
|
229
291
|
pendingNotes.clear();
|
|
292
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
230
293
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
231
294
|
});
|
|
232
295
|
}
|
|
233
296
|
|
|
297
|
+
function scriptSourceStoreFrom(pi: ExtensionAPI): ScriptSourceStore | undefined {
|
|
298
|
+
let store: ScriptSourceStore | undefined;
|
|
299
|
+
let count = 0;
|
|
300
|
+
emitTauEvent(pi, "tau:script-runner.source-store", {
|
|
301
|
+
accept(candidate) {
|
|
302
|
+
store = candidate;
|
|
303
|
+
count++;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
return count === 1 ? store : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
234
309
|
function approvalRequest(event: ToolCallEvent): ToolApprovalRequest | undefined {
|
|
235
310
|
if (isToolCallEventType("bash", event)) return { toolName: "bash", input: event.input };
|
|
236
311
|
if (isToolCallEventType<"script_runner", Record<string, unknown>>("script_runner", event)) {
|
|
@@ -252,10 +327,61 @@ function autoApprovedMarker(value: unknown): AutoApprovedMarker | undefined {
|
|
|
252
327
|
return { toolName: record.toolName, provider: record.provider, model: record.model };
|
|
253
328
|
}
|
|
254
329
|
|
|
255
|
-
async function
|
|
330
|
+
async function reviewAssistantRequests(
|
|
256
331
|
ctx: ExtensionContext,
|
|
257
|
-
|
|
258
|
-
|
|
332
|
+
event: ToolCallEvent,
|
|
333
|
+
currentRequest: ToolApprovalRequest,
|
|
334
|
+
scriptStore: ScriptSourceStore | undefined,
|
|
335
|
+
): Promise<Map<string, CachedReview>> {
|
|
336
|
+
const reviews = new Map<string, CachedReview>();
|
|
337
|
+
const assistant = ctx.sessionManager
|
|
338
|
+
.getBranch()
|
|
339
|
+
.reverse()
|
|
340
|
+
.find((entry) => entry.type === "message" && entry.message.role === "assistant");
|
|
341
|
+
if (!assistant || assistant.type !== "message" || assistant.message.role !== "assistant") return reviews;
|
|
342
|
+
if (
|
|
343
|
+
!assistant.message.content.some(
|
|
344
|
+
(part) => part.type === "toolCall" && part.id === event.toolCallId && part.name === currentRequest.toolName,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return reviews;
|
|
348
|
+
|
|
349
|
+
const requests = assistant.message.content.flatMap((part) => {
|
|
350
|
+
if (part.type !== "toolCall" || (part.name !== "bash" && part.name !== "script_runner")) return [];
|
|
351
|
+
const request: ToolApprovalRequest = {
|
|
352
|
+
toolName: part.name,
|
|
353
|
+
input: part.id === event.toolCallId ? currentRequest.input : part.arguments,
|
|
354
|
+
};
|
|
355
|
+
if (request.toolName === "bash") {
|
|
356
|
+
const command = request.input.command;
|
|
357
|
+
if (typeof command !== "string" || !command.trim() || isAllowlistedBash(command)) return [];
|
|
358
|
+
}
|
|
359
|
+
if (request.toolName === "script_runner" && part.id !== event.toolCallId) {
|
|
360
|
+
if (!scriptStore) return [];
|
|
361
|
+
try {
|
|
362
|
+
const { source } = scriptStore.resolve(request.input);
|
|
363
|
+
request.input = { ...request.input, script: source };
|
|
364
|
+
delete request.input.edits;
|
|
365
|
+
} catch {
|
|
366
|
+
// The sibling's own validated tool call will reject invalid or missing source.
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return [{ toolCallId: part.id, request, requestJson: JSON.stringify(request) }];
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
for (let index = 0; index < requests.length && !ctx.signal?.aborted; index += MAX_CONCURRENT_REVIEWS) {
|
|
374
|
+
const group = requests.slice(index, index + MAX_CONCURRENT_REVIEWS);
|
|
375
|
+
const outcomes = await Promise.allSettled(group.map((item) => reviewToolRequest(ctx, item.request)));
|
|
376
|
+
for (const [offset, item] of group.entries()) {
|
|
377
|
+
const outcome = outcomes[offset];
|
|
378
|
+
if (outcome) reviews.set(item.toolCallId, { requestJson: item.requestJson, outcome });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return reviews;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function reviewToolRequest(ctx: ExtensionContext, request: ToolApprovalRequest): Promise<ToolReviewResult> {
|
|
259
385
|
const requestJson = JSON.stringify(request);
|
|
260
386
|
const reviewer = REVIEW_MODELS.find((item) => item.provider === ctx.model?.provider);
|
|
261
387
|
const preferred = reviewer ? [reviewer] : [];
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
pushSavedNote,
|
|
16
16
|
rawHint,
|
|
17
17
|
renderNoteEditor,
|
|
18
|
+
ScrollableMarkdown,
|
|
18
19
|
ToolPanel,
|
|
19
20
|
type ToolPanelConfig,
|
|
20
21
|
wrapWithPrefix,
|
|
@@ -34,6 +35,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
34
35
|
private readonly noteEditor: Editor;
|
|
35
36
|
private readonly panelConfig: ToolPanelConfig;
|
|
36
37
|
private readonly panel: ToolPanel;
|
|
38
|
+
private readonly sourceView: ScrollableMarkdown | undefined;
|
|
37
39
|
private readonly notes: Record<ApprovalChoice, string> = { approve: "", reject: "" };
|
|
38
40
|
private choice: ApprovalChoice = "approve";
|
|
39
41
|
private editing = false;
|
|
@@ -45,6 +47,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
45
47
|
keys: KeybindingsManager,
|
|
46
48
|
title: string,
|
|
47
49
|
body: string,
|
|
50
|
+
scriptSource: string | undefined,
|
|
48
51
|
done: (answer: ApprovalAnswer | undefined) => void,
|
|
49
52
|
) {
|
|
50
53
|
this.tui = tui;
|
|
@@ -56,11 +59,16 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
56
59
|
this.notes[this.choice] = value.trim();
|
|
57
60
|
this.closeNote();
|
|
58
61
|
};
|
|
62
|
+
if (scriptSource !== undefined) {
|
|
63
|
+
const fenceLength = (scriptSource.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length + 1), 3);
|
|
64
|
+
const fence = "`".repeat(fenceLength);
|
|
65
|
+
this.sourceView = new ScrollableMarkdown(tui, `${fence}\n${scriptSource}\n${fence}`, 14);
|
|
66
|
+
}
|
|
59
67
|
this.panelConfig = {
|
|
60
68
|
title,
|
|
61
69
|
secondary: "Approve runs this request as shown. To change it, reject with a note.",
|
|
62
70
|
header: [body],
|
|
63
|
-
body: { render: (width) => this.renderChoices(width), invalidate: () =>
|
|
71
|
+
body: { render: (width) => this.renderChoices(width), invalidate: () => this.sourceView?.invalidate() },
|
|
64
72
|
footer: { kind: "hints", hints: this.hints() },
|
|
65
73
|
};
|
|
66
74
|
this.panel = new ToolPanel(theme, this.panelConfig);
|
|
@@ -90,7 +98,9 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
90
98
|
this.done(undefined);
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
|
-
if (this.
|
|
101
|
+
if (this.sourceView && (data === "j" || data === "k")) {
|
|
102
|
+
this.sourceView.scroll(data === "j" ? 1 : -1);
|
|
103
|
+
} else if (this.keys.matches(data, "tui.select.up") || this.keys.matches(data, "tui.select.down")) {
|
|
94
104
|
this.choice = this.choice === "approve" ? "reject" : "approve";
|
|
95
105
|
} else if (data === "n") {
|
|
96
106
|
this.editing = true;
|
|
@@ -113,6 +123,9 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
113
123
|
|
|
114
124
|
private renderChoices(width: number): string[] {
|
|
115
125
|
const lines: string[] = [];
|
|
126
|
+
if (this.sourceView) {
|
|
127
|
+
lines.push(this.theme.fg("muted", "Complete script:"), ...this.sourceView.render(width), "");
|
|
128
|
+
}
|
|
116
129
|
for (const choice of ["approve", "reject"] as const) {
|
|
117
130
|
const selected = choice === this.choice;
|
|
118
131
|
const prefix = selected ? this.theme.fg("accent", "→ ") : " ";
|
|
@@ -140,6 +153,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
140
153
|
? [bindingHint("tui.input.submit", "save"), bindingHint("tui.select.cancel", "cancel note")]
|
|
141
154
|
: [
|
|
142
155
|
bindingsHint(["tui.select.up", "tui.select.down"], "move"),
|
|
156
|
+
...(this.sourceView ? [rawHint("j/k", "scroll script")] : []),
|
|
143
157
|
bindingHint("tui.select.confirm", "choose"),
|
|
144
158
|
rawHint("n", "note"),
|
|
145
159
|
bindingHint("tui.select.cancel", "block"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ast-grep/wasm": "0.45.3",
|
|
38
|
-
"@shanepadgett/tau-tui": "0.
|
|
38
|
+
"@shanepadgett/tau-tui": "0.44.1",
|
|
39
39
|
"@vscode/tree-sitter-wasm": "0.3.1",
|
|
40
40
|
"image-size": "2.0.4",
|
|
41
41
|
"smol-toml": "1.8.0",
|
package/shared/events.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Tool } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ToolRowVisualState } from "./tool-row-state.js";
|
|
4
|
+
import type { ScriptSourceStore } from "./script-source.ts";
|
|
4
5
|
import type { FileInjectionRequest, PreparedFileInjection } from "../src/file-injection/index.ts";
|
|
5
6
|
|
|
6
7
|
export type TauAgentEvents = {
|
|
@@ -57,6 +58,10 @@ export type TauAgentEvents = {
|
|
|
57
58
|
request: FileInjectionRequest;
|
|
58
59
|
accept(preparation: Promise<PreparedFileInjection[]>): void;
|
|
59
60
|
};
|
|
61
|
+
/** @internal Script runner's live source store, used to review the code it will execute. */
|
|
62
|
+
"tau:script-runner.source-store": {
|
|
63
|
+
accept(store: ScriptSourceStore): void;
|
|
64
|
+
};
|
|
60
65
|
"tau:footer-item": {
|
|
61
66
|
id: string;
|
|
62
67
|
text?: string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
type Language = "python3" | "node" | "deno";
|
|
2
|
+
|
|
3
|
+
interface StoredScript {
|
|
4
|
+
language: Language;
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScriptSourceStore {
|
|
9
|
+
resolve(input: Record<string, unknown>): { scriptId: string | undefined; source: string };
|
|
10
|
+
remember(scriptId: string, language: Language, source: string): void;
|
|
11
|
+
forget(scriptId: string): void;
|
|
12
|
+
approve(toolCallId: string, input: Record<string, unknown>): void;
|
|
13
|
+
verifyAndConsume(toolCallId: string, input: Record<string, unknown>): void;
|
|
14
|
+
clearApprovals(): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_STORED = 8;
|
|
19
|
+
|
|
20
|
+
export function createScriptSourceStore(): ScriptSourceStore {
|
|
21
|
+
const scripts = new Map<string, StoredScript>();
|
|
22
|
+
const approvals = new Map<string, string>();
|
|
23
|
+
const store: ScriptSourceStore = {
|
|
24
|
+
resolve(input) {
|
|
25
|
+
const language = input.language;
|
|
26
|
+
if (language !== "python3" && language !== "node" && language !== "deno") {
|
|
27
|
+
throw new Error("Invalid script_runner language.");
|
|
28
|
+
}
|
|
29
|
+
const scriptId = input.scriptId;
|
|
30
|
+
if (scriptId !== undefined && typeof scriptId !== "string") {
|
|
31
|
+
throw new Error("Invalid scriptId.");
|
|
32
|
+
}
|
|
33
|
+
const edits = input.edits;
|
|
34
|
+
if (edits !== undefined && !Array.isArray(edits)) throw new Error("Invalid script edits.");
|
|
35
|
+
if (Array.isArray(edits) && edits.length > 0) {
|
|
36
|
+
if (!scriptId) throw new Error("edits require scriptId from the failed run.");
|
|
37
|
+
const stored = scripts.get(scriptId);
|
|
38
|
+
if (!stored) throw new Error(`No stored script for scriptId ${scriptId}. Evicted; resend full script.`);
|
|
39
|
+
if (stored.language !== language) {
|
|
40
|
+
throw new Error(`Language mismatch: scriptId ${scriptId} is ${stored.language}, not ${language}.`);
|
|
41
|
+
}
|
|
42
|
+
let source = stored.source;
|
|
43
|
+
for (const edit of edits) {
|
|
44
|
+
if (
|
|
45
|
+
!edit ||
|
|
46
|
+
typeof edit !== "object" ||
|
|
47
|
+
typeof edit.oldText !== "string" ||
|
|
48
|
+
typeof edit.newText !== "string"
|
|
49
|
+
) {
|
|
50
|
+
throw new Error("Invalid script edit.");
|
|
51
|
+
}
|
|
52
|
+
const idx = source.indexOf(edit.oldText);
|
|
53
|
+
if (idx === -1) throw new Error("edits oldText not found. Copy exact text from the script you wrote.");
|
|
54
|
+
source = source.slice(0, idx) + edit.newText + source.slice(idx + edit.oldText.length);
|
|
55
|
+
}
|
|
56
|
+
return { scriptId, source };
|
|
57
|
+
}
|
|
58
|
+
if (typeof input.script !== "string" || (input.script.length === 0 && !scriptId)) {
|
|
59
|
+
throw new Error("Provide script, or edits + scriptId.");
|
|
60
|
+
}
|
|
61
|
+
return { scriptId, source: input.script };
|
|
62
|
+
},
|
|
63
|
+
remember(scriptId, language, source) {
|
|
64
|
+
scripts.set(scriptId, { language, source });
|
|
65
|
+
while (scripts.size > MAX_STORED) {
|
|
66
|
+
const oldest = scripts.keys().next().value;
|
|
67
|
+
if (oldest === undefined) break;
|
|
68
|
+
scripts.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
forget(scriptId) {
|
|
72
|
+
scripts.delete(scriptId);
|
|
73
|
+
},
|
|
74
|
+
approve(toolCallId, input) {
|
|
75
|
+
approvals.set(toolCallId, JSON.stringify(input));
|
|
76
|
+
},
|
|
77
|
+
verifyAndConsume(toolCallId, input) {
|
|
78
|
+
const approved = approvals.get(toolCallId);
|
|
79
|
+
if (approved === undefined) return;
|
|
80
|
+
approvals.delete(toolCallId);
|
|
81
|
+
if (approved !== JSON.stringify(input)) {
|
|
82
|
+
throw new Error("script_runner request changed after approval; blocked.");
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
clearApprovals() {
|
|
86
|
+
approvals.clear();
|
|
87
|
+
},
|
|
88
|
+
clear() {
|
|
89
|
+
scripts.clear();
|
|
90
|
+
approvals.clear();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return store;
|
|
94
|
+
}
|