@shanepadgett/tau-agent 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/docs/extending-tau-agent.md +3 -3
  2. package/extensions/appshot/index.ts +3 -0
  3. package/extensions/aside/index.ts +3 -11
  4. package/extensions/attention/README.md +2 -4
  5. package/extensions/attention/index.ts +2 -40
  6. package/extensions/auto-name/index.ts +5 -8
  7. package/extensions/cache-diagnostics/index.ts +1 -1
  8. package/extensions/codex-priority/README.md +7 -0
  9. package/extensions/codex-priority/index.ts +95 -0
  10. package/extensions/compaction/README.md +5 -0
  11. package/extensions/compaction/index.ts +47 -0
  12. package/extensions/cost-report/README.md +1 -1
  13. package/extensions/cost-report/analyze.ts +11 -60
  14. package/extensions/cost-report/html.ts +1 -37
  15. package/extensions/cost-report/types.ts +0 -9
  16. package/extensions/handoff/index.ts +1 -1
  17. package/extensions/image-gen/index.ts +1 -0
  18. package/extensions/review/README.md +1 -1
  19. package/extensions/run-summary/README.md +1 -1
  20. package/extensions/run-summary/index.ts +6 -24
  21. package/extensions/runtime-context/README.md +1 -1
  22. package/extensions/script-runner/README.md +3 -1
  23. package/extensions/script-runner/index.ts +41 -96
  24. package/extensions/silent-command-runner/index.ts +38 -69
  25. package/extensions/soul/README.md +3 -5
  26. package/extensions/soul/index.ts +59 -135
  27. package/extensions/soul/prompt.ts +2 -0
  28. package/extensions/tau-help/help.md +10 -2
  29. package/extensions/tool-approval/README.md +3 -3
  30. package/extensions/tool-approval/index.ts +86 -47
  31. package/extensions/tool-approval/panel.ts +16 -2
  32. package/extensions/tool-loader/README.md +5 -5
  33. package/extensions/tool-loader/index.ts +10 -222
  34. package/extensions/web/codesearch.ts +1 -0
  35. package/extensions/web/webfetch.ts +1 -0
  36. package/extensions/web/websearch.ts +1 -0
  37. package/package.json +2 -2
  38. package/shared/events.ts +7 -20
  39. package/shared/model-effort.ts +12 -8
  40. package/shared/model-fallback/index.ts +12 -29
  41. package/shared/model-fallback/types.ts +1 -5
  42. package/shared/prompt-contributions.ts +0 -2
  43. package/shared/script-source.ts +94 -0
  44. package/src/tool-loading/index.ts +5 -42
  45. package/extensions/soul/context.ts +0 -115
  46. package/extensions/soul/state.ts +0 -114
  47. package/extensions/soul/tools.ts +0 -31
@@ -1,144 +1,68 @@
1
- import { getCurrentTools, toToolDeclaration } from "@earendil-works/pi-ai";
2
- import { createHash } from "node:crypto";
3
- import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { emitTauEvent, onTauEventImmediately } from "../../shared/events.ts";
5
- import { readPromptValues, renderBaseline, renderUpdate } from "./context.ts";
6
- import {
7
- admittedTools,
8
- BASELINE_TYPE,
9
- projectPrompt,
10
- restorePrompt,
11
- UPDATE_TYPE,
12
- type SavedBaseline,
13
- type SavedUpdate,
14
- } from "./state.ts";
15
- import { toolChangeReason } from "./tools.ts";
1
+ import { getDocsPath, getExamplesPath, getReadmePath, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { emitTauEvent } from "../../shared/events.ts";
3
+ import { collectPromptSources } from "../../shared/prompt-contributions.ts";
4
+ import { FIXED_INSTRUCTIONS } from "./prompt.ts";
16
5
 
17
- const CHECKPOINT_TYPE = "tau.soul.prefix";
18
- interface PrefixCheckpoint {
19
- baselineEntryId: string;
20
- count: number;
21
- hash: string;
22
- }
6
+ const CAPTURE_TYPE = "tau.soul.capture";
23
7
 
24
- export default function soulExtension(pi: ExtensionAPI): void {
25
- let inputs: BuildSystemPromptOptions | null = null;
8
+ const DOCUMENTATION = `Consult Pi or Tau documentation when the request concerns their usage or extension APIs.
9
+ Pi documentation:
10
+ - Main documentation: ${getReadmePath()}
11
+ - Additional docs: ${getDocsPath()}
12
+ - Examples: ${getExamplesPath()}
13
+ - Resolve docs/... and examples/... under those installed paths, not the working directory.
14
+ - Extensions: docs/extensions.md and examples/extensions/; themes: docs/themes.md; skills: docs/skills.md; prompt templates: docs/prompt-templates.md; TUI: docs/tui.md; keybindings: docs/keybindings.md; SDK: docs/sdk.md; providers: docs/custom-provider.md; models: docs/models.md; packages: docs/packages.md; environment: docs/environment-variables.md.
15
+ - Read the relevant documentation and follow related Markdown references before implementing Pi integrations.`;
26
16
 
27
- pi.on("session_start", () => {
28
- inputs = null;
29
- });
30
- pi.on("session_shutdown", () => {
31
- inputs = null;
32
- });
33
- pi.on("before_agent_start", (event) => {
34
- // Keep the shared options reference until all contributors have finished.
35
- inputs = event.systemPromptOptions;
36
- });
17
+ export default function soulExtension(pi: ExtensionAPI): void {
18
+ pi.on("before_agent_start", async (event, ctx) => {
19
+ const options = event.systemPromptOptions;
37
20
 
38
- onTauEventImmediately(pi, "soul.tools", "tau:prompt.tools.check", ({ ctx, tools, reject }) => {
39
- try {
40
- const saved = restorePrompt(ctx.sessionManager.getBranch());
41
- if (!saved || !ctx.model) return;
42
- const previous = admittedTools(saved);
43
- // getAllTools exposes schemas but not constrainedSampling. Preserve that
44
- // metadata here; the request boundary checks the complete Pi declarations.
45
- const reason = toolChangeReason(
46
- ctx.model,
47
- previous,
48
- tools.map((tool) => ({
49
- ...previous.find((candidate) => candidate.name === tool.name),
50
- ...tool,
51
- })),
52
- );
53
- if (reason) reject(reason);
54
- } catch (error) {
55
- reject(String(error));
21
+ // Sources that refresh on compaction are read once per compaction epoch and saved on the branch.
22
+ const branch = ctx.sessionManager.getBranch();
23
+ let epochStart = 0;
24
+ branch.forEach((entry, index) => {
25
+ if (entry.type === "compaction") epochStart = index + 1;
26
+ });
27
+ let captured: Record<string, string> = {};
28
+ for (const entry of branch.slice(epochStart)) {
29
+ if (entry.type === "custom" && entry.customType === CAPTURE_TYPE)
30
+ captured = entry.data as Record<string, string>;
31
+ }
32
+ const sources = collectPromptSources(pi);
33
+ const missing = sources.filter((source) => source.refresh === "compaction" && !(source.key in captured));
34
+ if (missing.length > 0) {
35
+ const read = await Promise.all(missing.map(async (source) => [source.key, await source.read(ctx)] as const));
36
+ captured = { ...captured, ...Object.fromEntries(read) };
37
+ pi.appendEntry(CAPTURE_TYPE, captured);
56
38
  }
57
- });
58
39
 
59
- pi.on("context_with_system", async (event, ctx) => {
60
- try {
61
- if (!inputs) throw new Error("Soul has no loaded prompt inputs.");
62
- if (inputs.forceSystemPrompt !== undefined)
63
- throw new Error(
64
- "A forced system prompt conflicts with Soul. Remove the extension's systemPrompt replacement.",
65
- );
66
- const branch = ctx.sessionManager.getBranch();
67
- const newestFirst = [...branch].reverse();
68
- const projection = ctx.sessionManager.buildSessionProjection();
69
- const anchor = [...projection.entries].reverse().find((entry) => entry.messages.length > 0);
70
- if (!anchor) throw new Error("Soul has no conversation anchor.");
71
- const saved = restorePrompt(branch);
72
- const active = new Set(pi.getActiveTools());
73
- const requested = getCurrentTools(event.messages)
74
- .filter((tool) => active.has(tool.name))
75
- .map(toToolDeclaration);
76
- if (saved && ctx.model) {
77
- const reason = toolChangeReason(ctx.model, admittedTools(saved), requested);
78
- if (reason) throw new Error(reason);
79
- }
80
- const values = await readPromptValues(pi, ctx, inputs, saved === null);
81
- if (!saved) {
82
- pi.appendEntry<SavedBaseline>(BASELINE_TYPE, {
83
- version: 1,
84
- compactionId: newestFirst.find((entry) => entry.type === "compaction")?.id ?? null,
85
- afterEntryId: anchor.sourceEntry.id,
86
- text: renderBaseline(inputs, values),
87
- values,
88
- initialTools: getCurrentTools(event.messages),
89
- });
90
- } else {
91
- const previous = new Map(saved.baseline.values.map((value) => [value.key, value]));
92
- for (const update of saved.updates) for (const value of update.data.values) previous.set(value.key, value);
93
- const currentKeys = new Set(values.map((value) => value.key));
94
- for (const value of previous.values()) {
95
- if (value.refresh === "append" && !currentKeys.has(value.key)) values.push({ ...value, text: "" });
96
- }
97
- const changed = values.filter((value) => previous.get(value.key)?.text !== value.text);
98
- const tools = new Map(admittedTools(saved).map((tool) => [tool.name, tool]));
99
- const added = requested.some((tool) => !tools.has(tool.name));
100
- for (const tool of requested) tools.set(tool.name, tool);
101
- if (changed.length > 0 || added)
102
- pi.appendEntry<SavedUpdate>(UPDATE_TYPE, {
103
- version: 1,
104
- baselineEntryId: saved.entryId,
105
- afterEntryId: anchor.sourceEntry.id,
106
- text: renderUpdate(changed),
107
- values: changed,
108
- tools: [...tools.values()],
109
- });
110
- }
111
- const admitted = restorePrompt(ctx.sessionManager.getBranch());
112
- if (!admitted) throw new Error("Soul failed to save its baseline.");
113
- const messages = projectPrompt(event.messages, admitted, ctx);
114
- const checkpointEntry = newestFirst.find(
115
- (entry) => entry.type === "custom" && entry.customType === CHECKPOINT_TYPE,
116
- );
117
- const checkpoint = checkpointEntry?.type === "custom" ? (checkpointEntry.data as PrefixCheckpoint) : null;
118
- if (checkpoint?.baselineEntryId === admitted.entryId) {
119
- const prefix = createHash("sha256")
120
- .update(JSON.stringify(messages.slice(0, checkpoint.count)))
121
- .digest("hex");
122
- if (prefix !== checkpoint.hash)
123
- throw new Error("Previously sent conversation content changed. Compact before continuing.");
124
- }
125
- const hash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
126
- if (hash !== checkpoint?.hash)
127
- pi.appendEntry<PrefixCheckpoint>(CHECKPOINT_TYPE, {
128
- baselineEntryId: admitted.entryId,
129
- count: messages.length,
130
- hash,
131
- });
132
- emitTauEvent(pi, "tau:prompt.snapshot", {
133
- text: [admitted.baseline.text, ...admitted.updates.map((update) => update.data.text)].join("\n\n"),
134
- });
135
- return { messages };
136
- } catch (error) {
137
- ctx.ui.notify(`Soul stopped this request: ${error instanceof Error ? error.message : String(error)}`, "error");
138
- ctx.abort();
139
- // Pi currently enters the provider with an aborted signal; do not weaken that
140
- // cancellation into a fallback prompt. See soul-request-check-findings.md.
141
- return undefined;
40
+ const active = pi.getActiveTools();
41
+ const guidance = [
42
+ ...new Set([
43
+ ...(active.includes("bash") ? ["Use bash for file operations like ls, rg, find."] : []),
44
+ ...active.flatMap((name) => options.toolGuidelines[name] ?? []),
45
+ ...options.promptGuidelines,
46
+ ]),
47
+ ]
48
+ .map((rule) => `- ${rule}`)
49
+ .join("\n");
50
+
51
+ const sections = new Map<string, string[]>();
52
+ const add = (section: string, text: string) => {
53
+ if (text.trim()) sections.set(section, [...(sections.get(section) ?? []), text]);
54
+ };
55
+ add("documentation", DOCUMENTATION);
56
+ add("tool-guidance", guidance);
57
+ if (options.customPrompt) add("additional-instructions", options.customPrompt);
58
+ for (const source of sources) {
59
+ add(source.section, source.refresh === "append" ? await source.read(ctx) : (captured[source.key] ?? ""));
142
60
  }
61
+
62
+ // Pi diffs these sections against the prompt already in the transcript and appends a patch only for
63
+ // sections whose text changed, so unchanged sections keep the cached prefix.
64
+ options.customPrompt = FIXED_INSTRUCTIONS;
65
+ for (const name of [...sections.keys()].sort()) options.sections[name] = (sections.get(name) ?? []).join("\n\n");
66
+ emitTauEvent(pi, "tau:prompt.snapshot", { text: event.systemPrompt });
143
67
  });
144
68
  }
@@ -9,6 +9,8 @@ Use headings or tables when they improve clarity. In conversational, personal, o
9
9
  Use technical terms when they help. Keep paths, commands, API names, and errors exact.
10
10
  State the intended action directly. Avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results.
11
11
  Give useful facts instead of praise, ceremony, or commentary about following instructions.
12
+ Talk about the user's work, not the machinery directing your behavior. Do not volunteer commentary about system prompts, tool prompts, internal instructions, the harness, or automatic validation. Discuss those mechanisms only when the user asks about them as the subject of the work.
13
+ Finish with the concrete result. Do not hedge completion because silent validation is pending, announce that checks will run or rerun, or explain what happens when you end the turn. When a failure is reported, fix it and describe the correction without narrating the validation process.
12
14
  You are a partner, and the user expects you to act like one.
13
15
  </communication>
14
16
 
@@ -32,6 +32,10 @@ Records private prompt-cache fingerprints without storing prompt content. Run `/
32
32
 
33
33
  Adds `/clear-screen` to clear terminal output without changing the session.
34
34
 
35
+ ## codex-priority
36
+
37
+ Requests Codex priority processing (Fast mode) for every `gpt-6-luna` request on the `openai-codex` provider, including the agent loop, compaction, tool approval, auto-naming, commit, and handoff. It has no command or setting. Cost uses OpenAI's 2.5x Fast mode rate for GPT-6 models. Do not run another extension that overlays `openai-codex` or sets `service_tier`.
38
+
35
39
  ## commit
36
40
 
37
41
  Adds `/commit` for semantic commit grouping, review, and committing selected repository changes.
@@ -40,6 +44,10 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
40
44
 
41
45
  Adds `/cost-report` to build an HTML spend report from local session usage. Pick a time frame (past 7 days, current week, current month, year to date, or a specific month) and scope (current project or all sessions). Tau scans sessions, writes under `~/.pi/tau/cost-reports/`, opens the file, and notifies with the path. Empty windows warn without writing a file.
42
46
 
47
+ ## compaction
48
+
49
+ Writes compaction summaries (automatic, `/compact`, and overflow recovery) with a cheaper model from the same provider as the session, so Opus, GPT-6.1 Sol, and Astra sessions do not pay their own rates to summarize themselves. Summaries never cross providers, and a notice names the model that wrote each one. When no cheaper model fits, Pi's default compaction runs.
50
+
43
51
  ## context
44
52
 
45
53
  Adds `/context` to browse and inject reusable repository work scopes from `.pi/contexts`. Selecting entries injects them once into the conversation: `read` paths as complete files, `show` targets as current declaration slices, `outline` paths as Explore structures, and one hidden note listing `references` plus instructions to treat the injected material as current. Run `/context` again to inject more. Edit catalog files by hand when work scopes change. Domain folders are `NN_slug` tabs (ordered by the two-digit prefix; UI shows the slug), TOML files are concepts, and TOML sections are selectable entries.
@@ -106,7 +114,7 @@ Runs configured commands while keeping their output out of agent context when th
106
114
 
107
115
  ## soul
108
116
 
109
- Supplies Tau's communication, discussion, planning, execution, and coding instructions. Saves a prompt baseline across turns, reload, and resume; refreshes it after successful compaction. Operational changes arrive as saved context updates without rewriting earlier instructions. Tool groups that cannot load without changing the cached prefix wait for successful compaction.
117
+ Supplies Tau's communication, discussion, planning, execution, and coding instructions, plus tool guidance and context from other Tau extensions. The date and directory snapshot stay fixed until successful compaction. Other changes, such as an edited `AGENTS.md` after `/reload`, arrive as appended updates without rewriting earlier instructions.
110
118
 
111
119
  ## stash
112
120
 
@@ -126,7 +134,7 @@ Reviews agent `bash` and `script_runner` requests before they run. Common read-o
126
134
 
127
135
  ## tool-loader
128
136
 
129
- Progressively exposes registered specialist tool groups through `load_tools`. Tau registers `web`, `image`, and `appshot`; project or global package extensions can add groups with `registerDeferredToolGroup()` from `@shanepadgett/tau-agent`. Compatible models load tools without replacing the cached prefix. Otherwise, requested groups are queued until successful compaction; loading never triggers compaction automatically.
137
+ Keeps specialist tool groups out of every request as deferred tools and lets the agent load them with Pi's `tool_search`. Tau registers `web`, `image`, and `appshot`; project or global package extensions can add groups with `registerDeferredToolGroup()` from `@shanepadgett/tau-agent`. All models can load tools. Compatible models preserve the cached prefix; other models can incur a cache miss when tools are activated. Loading never triggers compaction.
130
138
 
131
139
  ## web
132
140
 
@@ -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
 
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel, Tool } from "@earendil-works/pi-ai";
1
+ import type { Tool } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  isToolCallEventType,
4
4
  type ExtensionAPI,
@@ -8,7 +8,10 @@ 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 { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
11
+ import { emitTauEvent } from "../../shared/events.ts";
12
+ import { generateToolValidated } from "../../shared/model-fallback/index.ts";
13
+ import { resolveEffortCandidates } from "../../shared/model-effort.ts";
14
+ import type { ScriptSourceStore } from "../../shared/script-source.ts";
12
15
  import { errorText, truncAt } from "../../shared/text.ts";
13
16
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
14
17
  import { isAllowlistedBash } from "./allowlist.ts";
@@ -57,15 +60,6 @@ const REVIEW_TOOL = {
57
60
  parameters: REVIEW_SCHEMA,
58
61
  } satisfies Tool;
59
62
 
60
- const REVIEW_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
61
- { provider: "openai", model: "gpt-6-luna", reasoning: "medium" },
62
- { provider: "openai-codex", model: "gpt-6-luna", reasoning: "medium" },
63
- { provider: "anthropic", model: "claude-sonnet-5", reasoning: "medium" },
64
- { provider: "xai", model: "grok-4.5", reasoning: "low" },
65
- { provider: "openrouter", model: "deepseek/deepseek-v4.1-flash", reasoning: "high" },
66
- { provider: "opencode-go", model: "deepseek-v4.1-flash", reasoning: "high" },
67
- ];
68
-
69
63
  type ToolReview =
70
64
  | { decision: "approved"; summary: string }
71
65
  | { decision: "requires_user_approval"; summary: string; reason: string };
@@ -112,10 +106,16 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
112
106
  async function requestToolApproval(
113
107
  ctx: ExtensionContext,
114
108
  toolCallId: string,
115
- toolName: ApprovalToolName,
109
+ request: ToolApprovalRequest,
116
110
  title: string,
117
111
  body: string,
118
112
  ): Promise<{ block: true; reason: string } | undefined> {
113
+ const toolName = request.toolName;
114
+ const source =
115
+ toolName === "script_runner" && typeof request.input.script === "string" ? request.input.script : undefined;
116
+ if (toolName === "script_runner" && source === undefined) {
117
+ return block("script_runner source is unavailable for manual approval");
118
+ }
119
119
  if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
120
120
  try {
121
121
  emitAgentBlocked(pi, {
@@ -124,11 +124,14 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
124
124
  source: "tool-approval.review",
125
125
  });
126
126
  if (ctx.mode !== "tui") {
127
- const confirmed = await ctx.ui.confirm(title, body);
127
+ const confirmed = await ctx.ui.confirm(
128
+ title,
129
+ source === undefined ? body : `${body}\n\nFull script:\n${source}`,
130
+ );
128
131
  return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
129
132
  }
130
133
  const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
131
- (tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, done),
134
+ (tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, source, done),
132
135
  );
133
136
  if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
134
137
  const note = truncAt(answer.note, 800);
@@ -147,6 +150,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
147
150
  pi.on("session_start", async (_event, ctx) => {
148
151
  batchReviews = undefined;
149
152
  pendingNotes.clear();
153
+ scriptSourceStoreFrom(pi)?.clearApprovals();
150
154
  await refreshSettings(ctx);
151
155
  });
152
156
 
@@ -161,6 +165,17 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
161
165
  return block(`tool approval settings failed to load: ${truncAt(message, 600)}`);
162
166
  }
163
167
  if (!settings.enabled) return undefined;
168
+ const scriptStore = scriptSourceStoreFrom(pi);
169
+ if (request.toolName === "script_runner") {
170
+ try {
171
+ if (!scriptStore) return block("script_runner source store is unavailable for review");
172
+ const { source } = scriptStore.resolve(request.input);
173
+ request.input.script = source;
174
+ delete request.input.edits;
175
+ } catch (error) {
176
+ return block(`script_runner source could not be reviewed: ${errorText(error)}`);
177
+ }
178
+ }
164
179
 
165
180
  if (request.toolName === "bash") {
166
181
  const command = request.input.command;
@@ -174,7 +189,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
174
189
 
175
190
  ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
176
191
  try {
177
- batchReviews ??= await reviewAssistantRequests(ctx, event, request);
192
+ batchReviews ??= await reviewAssistantRequests(ctx, event, request, scriptStore);
178
193
  if (ctx.signal?.aborted) return block("Tool review cancelled");
179
194
  const cached = batchReviews.get(event.toolCallId);
180
195
  batchReviews.delete(event.toolCallId);
@@ -188,41 +203,51 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
188
203
  }
189
204
  if (ctx.signal?.aborted) return block("Tool review cancelled");
190
205
  const { review, provider, model } = result;
206
+ let rejected: { block: true; reason: string } | undefined;
191
207
  if (review.decision === "requires_user_approval") {
192
- return requestToolApproval(
208
+ rejected = await requestToolApproval(
193
209
  ctx,
194
210
  event.toolCallId,
195
- request.toolName,
211
+ request,
196
212
  `Approve high-impact ${toolLabel(request.toolName)}?`,
197
213
  formatApproval(review.summary, review.reason),
198
214
  );
199
- }
200
- if (settings.autoApprove) {
215
+ } else if (settings.autoApprove) {
201
216
  pi.appendEntry<AutoApprovedMarker>(AUTO_APPROVED_TYPE, {
202
217
  toolName: request.toolName,
203
218
  provider,
204
219
  model,
205
220
  });
206
- return undefined;
221
+ } else {
222
+ rejected = await requestToolApproval(
223
+ ctx,
224
+ event.toolCallId,
225
+ request,
226
+ `Run reviewed ${toolLabel(request.toolName)}?`,
227
+ formatApproval(review.summary, "Automatic approval is disabled."),
228
+ );
207
229
  }
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
- );
230
+ if (!rejected && request.toolName === "script_runner") {
231
+ if (!scriptStore) return block("script_runner source store is unavailable for approval");
232
+ scriptStore.approve(event.toolCallId, request.input);
233
+ }
234
+ return rejected;
215
235
  } catch (error) {
216
236
  if (ctx.signal?.aborted) return block("Tool review cancelled");
217
237
  const message = singleLine(errorText(error));
218
238
  ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
219
- return requestToolApproval(
239
+ const rejected = await requestToolApproval(
220
240
  ctx,
221
241
  event.toolCallId,
222
- request.toolName,
242
+ request,
223
243
  `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.`,
244
+ `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
245
  );
246
+ if (!rejected && request.toolName === "script_runner") {
247
+ if (!scriptStore) return block("script_runner source store is unavailable for approval");
248
+ scriptStore.approve(event.toolCallId, request.input);
249
+ }
250
+ return rejected;
226
251
  } finally {
227
252
  ctx.ui.setStatus(STATUS_KEY, undefined);
228
253
  }
@@ -250,15 +275,29 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
250
275
  pi.on("agent_end", () => {
251
276
  batchReviews = undefined;
252
277
  pendingNotes.clear();
278
+ scriptSourceStoreFrom(pi)?.clearApprovals();
253
279
  });
254
280
 
255
281
  pi.on("session_shutdown", (_event, ctx) => {
256
282
  batchReviews = undefined;
257
283
  pendingNotes.clear();
284
+ scriptSourceStoreFrom(pi)?.clearApprovals();
258
285
  ctx.ui.setStatus(STATUS_KEY, undefined);
259
286
  });
260
287
  }
261
288
 
289
+ function scriptSourceStoreFrom(pi: ExtensionAPI): ScriptSourceStore | undefined {
290
+ let store: ScriptSourceStore | undefined;
291
+ let count = 0;
292
+ emitTauEvent(pi, "tau:script-runner.source-store", {
293
+ accept(candidate) {
294
+ store = candidate;
295
+ count++;
296
+ },
297
+ });
298
+ return count === 1 ? store : undefined;
299
+ }
300
+
262
301
  function approvalRequest(event: ToolCallEvent): ToolApprovalRequest | undefined {
263
302
  if (isToolCallEventType("bash", event)) return { toolName: "bash", input: event.input };
264
303
  if (isToolCallEventType<"script_runner", Record<string, unknown>>("script_runner", event)) {
@@ -284,6 +323,7 @@ async function reviewAssistantRequests(
284
323
  ctx: ExtensionContext,
285
324
  event: ToolCallEvent,
286
325
  currentRequest: ToolApprovalRequest,
326
+ scriptStore: ScriptSourceStore | undefined,
287
327
  ): Promise<Map<string, CachedReview>> {
288
328
  const reviews = new Map<string, CachedReview>();
289
329
  const assistant = ctx.sessionManager
@@ -308,6 +348,17 @@ async function reviewAssistantRequests(
308
348
  const command = request.input.command;
309
349
  if (typeof command !== "string" || !command.trim() || isAllowlistedBash(command)) return [];
310
350
  }
351
+ if (request.toolName === "script_runner" && part.id !== event.toolCallId) {
352
+ if (!scriptStore) return [];
353
+ try {
354
+ const { source } = scriptStore.resolve(request.input);
355
+ request.input = { ...request.input, script: source };
356
+ delete request.input.edits;
357
+ } catch {
358
+ // The sibling's own validated tool call will reject invalid or missing source.
359
+ return [];
360
+ }
361
+ }
311
362
  return [{ toolCallId: part.id, request, requestJson: JSON.stringify(request) }];
312
363
  });
313
364
 
@@ -324,23 +375,11 @@ async function reviewAssistantRequests(
324
375
 
325
376
  async function reviewToolRequest(ctx: ExtensionContext, request: ToolApprovalRequest): Promise<ToolReviewResult> {
326
377
  const requestJson = JSON.stringify(request);
327
- const reviewer = REVIEW_MODELS.find((item) => item.provider === ctx.model?.provider);
328
- const preferred = reviewer ? [reviewer] : [];
329
- if (ctx.model) {
330
- preferred.push({
331
- provider: ctx.model.provider,
332
- model: ctx.model.id,
333
- reasoning: "medium",
334
- });
335
- }
336
- const candidates = await resolveCandidates(ctx, preferred, false);
337
- const wanted = preferred[0];
338
- if (
339
- wanted &&
340
- !candidates.some((item) => item.model.provider === wanted.provider && item.model.id === wanted.model)
341
- ) {
342
- ctx.ui.notify(`Tool review skipped ${wanted.provider}/${wanted.model}; trying next model.`, "info");
343
- }
378
+ // Requests contain shell commands and scripts, so only the session's own provider reviews them.
379
+ const provider = ctx.model?.provider;
380
+ const candidates = (
381
+ await resolveEffortCandidates(ctx, "quick", { includeParentModel: true, preferredProvider: provider })
382
+ ).filter((candidate) => candidate.model.provider === provider);
344
383
  const { value, candidate } = await generateToolValidated(
345
384
  ctx,
346
385
  candidates,
@@ -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"),
@@ -1,15 +1,15 @@
1
1
  # Tool Loader
2
2
 
3
- Tau progressively exposes registered specialist tool groups. Most coding turns do not need web, image, macOS application, or application-specific schemas, so Pi can load those tools later without discarding supported provider cache prefixes.
3
+ Tau registers specialist tool groups as deferred tools so their schemas stay out of every request. Most coding turns do not need web, image, macOS application, or application-specific schemas. The agent finds and loads them with Pi's built-in `tool_search` tool, which Tau keeps active whenever deferred tools exist.
4
4
 
5
- The agent normally calls `load_tools` itself. Tau's built-in groups are:
5
+ Tau's built-in groups are:
6
6
 
7
7
  - `web` for public web and implementation research
8
8
  - `image` for raster image generation and editing
9
9
  - `appshot` for macOS window discovery, capture, and activation
10
10
 
11
- Project and global package extensions can add groups with `registerDeferredToolGroup()` from `@shanepadgett/tau-agent`. The group description is included in the loader catalog so the agent can select it when a task needs that capability.
11
+ Project and global package extensions can add groups with `registerDeferredToolGroup()` from `@shanepadgett/tau-agent`. The group id and description become the tool namespace shown to the agent.
12
12
 
13
- Supported models load new groups without replacing the cached prompt prefix. If the selected model cannot do that, the group is queued for activation after successful compaction. The loader reports this and does not compact automatically.
13
+ Loaded tools are recorded in the session and stay available on that branch. All models can discover and load deferred tools. Supported models preserve the cached prompt prefix; other models, including Grok and opencode-go, can incur a cache miss when tools are activated. Tau does not compact automatically.
14
14
 
15
- After changing this extension during development, run `/reload` before testing.
15
+ Pi's built-in `tool_search` extension must be enabled. After changing this extension during development, run `/reload` before testing.