@shanepadgett/tau-agent 0.44.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,16 +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
-
173
- function remember(scriptId: string, script: StoredScript): void {
174
- scripts.set(scriptId, script);
175
- while (scripts.size > MAX_STORED) {
176
- const oldest = scripts.keys().next().value;
177
- if (oldest === undefined) break;
178
- scripts.delete(oldest);
179
- }
180
- }
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
+ });
181
108
 
182
109
  function resolveCommand(language: Language): string {
183
110
  const cmd = runtimes[language];
@@ -265,24 +192,41 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
265
192
  "script_runner never exposes the script path; you already have the source. Never try to read it back.",
266
193
  ],
267
194
  parameters: paramsSchema,
268
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
195
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
269
196
  const language = params.language;
270
197
  if (signal?.aborted) {
271
198
  return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
272
199
  }
200
+ scriptStore.verifyAndConsume(toolCallId, params);
273
201
  const command = resolveCommand(language);
274
- const { scriptId, source } = resolveScriptSource(scripts, language, params);
275
- 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);
276
206
  await onUpdate?.({
277
207
  content: [{ type: "text", text: `Running ${languageLabel(language)}...` }],
278
208
  details: undefined,
279
209
  });
280
210
  const result = await runScript(language, command, source, ctx.cwd, signal);
281
- if (result.code === 0 && !result.killed) {
282
- scripts.delete(scriptId);
283
- 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 };
284
228
  }
285
- throwScriptFailure(scriptId, result);
229
+ throw new Error(`${content ? `${content}\n\n` : ""}scriptId: ${scriptId}`);
286
230
  },
287
231
  renderCall(args, theme, context) {
288
232
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
@@ -312,7 +256,8 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
312
256
 
313
257
  pi.registerTool(tool);
314
258
 
315
- pi.on("session_shutdown", () => {
316
- scripts.clear();
259
+ pi.on("session_shutdown", async () => {
260
+ scriptStore.clear();
261
+ await temporaryOutput.shutdown();
317
262
  });
318
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. 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.
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 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.
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";
@@ -112,10 +114,16 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
112
114
  async function requestToolApproval(
113
115
  ctx: ExtensionContext,
114
116
  toolCallId: string,
115
- toolName: ApprovalToolName,
117
+ request: ToolApprovalRequest,
116
118
  title: string,
117
119
  body: string,
118
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
+ }
119
127
  if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
120
128
  try {
121
129
  emitAgentBlocked(pi, {
@@ -124,11 +132,14 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
124
132
  source: "tool-approval.review",
125
133
  });
126
134
  if (ctx.mode !== "tui") {
127
- 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
+ );
128
139
  return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
129
140
  }
130
141
  const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
131
- (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),
132
143
  );
133
144
  if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
134
145
  const note = truncAt(answer.note, 800);
@@ -147,6 +158,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
147
158
  pi.on("session_start", async (_event, ctx) => {
148
159
  batchReviews = undefined;
149
160
  pendingNotes.clear();
161
+ scriptSourceStoreFrom(pi)?.clearApprovals();
150
162
  await refreshSettings(ctx);
151
163
  });
152
164
 
@@ -161,6 +173,17 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
161
173
  return block(`tool approval settings failed to load: ${truncAt(message, 600)}`);
162
174
  }
163
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
+ }
164
187
 
165
188
  if (request.toolName === "bash") {
166
189
  const command = request.input.command;
@@ -174,7 +197,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
174
197
 
175
198
  ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
176
199
  try {
177
- batchReviews ??= await reviewAssistantRequests(ctx, event, request);
200
+ batchReviews ??= await reviewAssistantRequests(ctx, event, request, scriptStore);
178
201
  if (ctx.signal?.aborted) return block("Tool review cancelled");
179
202
  const cached = batchReviews.get(event.toolCallId);
180
203
  batchReviews.delete(event.toolCallId);
@@ -188,41 +211,51 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
188
211
  }
189
212
  if (ctx.signal?.aborted) return block("Tool review cancelled");
190
213
  const { review, provider, model } = result;
214
+ let rejected: { block: true; reason: string } | undefined;
191
215
  if (review.decision === "requires_user_approval") {
192
- return requestToolApproval(
216
+ rejected = await requestToolApproval(
193
217
  ctx,
194
218
  event.toolCallId,
195
- request.toolName,
219
+ request,
196
220
  `Approve high-impact ${toolLabel(request.toolName)}?`,
197
221
  formatApproval(review.summary, review.reason),
198
222
  );
199
- }
200
- if (settings.autoApprove) {
223
+ } else if (settings.autoApprove) {
201
224
  pi.appendEntry<AutoApprovedMarker>(AUTO_APPROVED_TYPE, {
202
225
  toolName: request.toolName,
203
226
  provider,
204
227
  model,
205
228
  });
206
- 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
+ );
207
237
  }
208
- return requestToolApproval(
209
- ctx,
210
- event.toolCallId,
211
- request.toolName,
212
- `Run reviewed ${toolLabel(request.toolName)}?`,
213
- formatApproval(review.summary, "Automatic approval is disabled."),
214
- );
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;
215
243
  } catch (error) {
216
244
  if (ctx.signal?.aborted) return block("Tool review cancelled");
217
245
  const message = singleLine(errorText(error));
218
246
  ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
219
- return requestToolApproval(
247
+ const rejected = await requestToolApproval(
220
248
  ctx,
221
249
  event.toolCallId,
222
- request.toolName,
250
+ request,
223
251
  `Automatic ${toolLabel(request.toolName)} review failed. Continue?`,
224
- `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"}.`,
225
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;
226
259
  } finally {
227
260
  ctx.ui.setStatus(STATUS_KEY, undefined);
228
261
  }
@@ -250,15 +283,29 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
250
283
  pi.on("agent_end", () => {
251
284
  batchReviews = undefined;
252
285
  pendingNotes.clear();
286
+ scriptSourceStoreFrom(pi)?.clearApprovals();
253
287
  });
254
288
 
255
289
  pi.on("session_shutdown", (_event, ctx) => {
256
290
  batchReviews = undefined;
257
291
  pendingNotes.clear();
292
+ scriptSourceStoreFrom(pi)?.clearApprovals();
258
293
  ctx.ui.setStatus(STATUS_KEY, undefined);
259
294
  });
260
295
  }
261
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
+
262
309
  function approvalRequest(event: ToolCallEvent): ToolApprovalRequest | undefined {
263
310
  if (isToolCallEventType("bash", event)) return { toolName: "bash", input: event.input };
264
311
  if (isToolCallEventType<"script_runner", Record<string, unknown>>("script_runner", event)) {
@@ -284,6 +331,7 @@ async function reviewAssistantRequests(
284
331
  ctx: ExtensionContext,
285
332
  event: ToolCallEvent,
286
333
  currentRequest: ToolApprovalRequest,
334
+ scriptStore: ScriptSourceStore | undefined,
287
335
  ): Promise<Map<string, CachedReview>> {
288
336
  const reviews = new Map<string, CachedReview>();
289
337
  const assistant = ctx.sessionManager
@@ -308,6 +356,17 @@ async function reviewAssistantRequests(
308
356
  const command = request.input.command;
309
357
  if (typeof command !== "string" || !command.trim() || isAllowlistedBash(command)) return [];
310
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
+ }
311
370
  return [{ toolCallId: part.id, request, requestJson: JSON.stringify(request) }];
312
371
  });
313
372
 
@@ -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.44.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.44.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
+ }