@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.
@@ -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 scripts = new Map<string, StoredScript>();
172
- let tempDir: string | undefined;
173
-
174
- function remember(scriptId: string, script: StoredScript): void {
175
- scripts.set(scriptId, script);
176
- while (scripts.size > MAX_STORED) {
177
- const oldest = scripts.keys().next().value;
178
- if (oldest === undefined) break;
179
- scripts.delete(oldest);
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 ensureTempDir();
207
- const file = join(dir, language === "python3" ? "_run.py" : "_run.ts");
208
- await writeFile(file, source, "utf8");
209
- const args =
210
- language === "python3"
211
- ? [file]
212
- : language === "node"
213
- ? ["--experimental-strip-types", file]
214
- : ["run", "-A", file];
215
- const result = await pi.exec(command, args, { cwd, signal, timeout: TIMEOUT_MS });
216
- return {
217
- ...result,
218
- stdout: scrubPath(result.stdout, file, dir),
219
- stderr: scrubPath(result.stderr, file, dir),
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(_toolCallId, params, signal, onUpdate, ctx) {
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 { scriptId, source } = resolveScriptSource(scripts, language, params);
276
- remember(scriptId, { language, source });
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
- if (result.code === 0 && !result.killed) {
283
- scripts.delete(scriptId);
284
- return successScriptResult(result.stdout);
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
- throwScriptFailure(scriptId, result);
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
- scripts.clear();
318
- const dir = tempDir;
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
- toolName: ApprovalToolName,
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(title, body);
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 value = request.input.command;
158
- if (typeof value !== "string") return block("bash command was malformed");
159
- if (!value.trim()) {
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
- const { review, provider, model } = await reviewToolRequest(ctx, request);
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
- return requestToolApproval(
216
+ rejected = await requestToolApproval(
172
217
  ctx,
173
218
  event.toolCallId,
174
- request.toolName,
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
- return undefined;
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
- return requestToolApproval(
188
- ctx,
189
- event.toolCallId,
190
- request.toolName,
191
- `Run reviewed ${toolLabel(request.toolName)}?`,
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
- return requestToolApproval(
247
+ const rejected = await requestToolApproval(
198
248
  ctx,
199
249
  event.toolCallId,
200
- request.toolName,
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 reviewToolRequest(
330
+ async function reviewAssistantRequests(
256
331
  ctx: ExtensionContext,
257
- request: ToolApprovalRequest,
258
- ): Promise<{ review: ToolReview; provider: string; model: string }> {
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.keys.matches(data, "tui.select.up") || this.keys.matches(data, "tui.select.down")) {
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.43.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.43.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
+ }