@shanepadgett/tau-agent 0.42.2 → 0.43.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 (69) hide show
  1. package/README.md +0 -1
  2. package/docs/context.md +1 -1
  3. package/docs/extending-tau-agent.md +1 -2
  4. package/extensions/attention/README.md +1 -1
  5. package/extensions/cache-diagnostics/index.ts +4 -0
  6. package/extensions/context/README.md +1 -24
  7. package/extensions/context/definitions.ts +1 -52
  8. package/extensions/context/index.ts +1 -150
  9. package/extensions/context/panel.ts +0 -72
  10. package/extensions/explore/guidance.ts +9 -1
  11. package/extensions/footer/README.md +1 -1
  12. package/extensions/footer/index.ts +3 -25
  13. package/extensions/qna/choice-question-body.ts +7 -2
  14. package/extensions/run-summary/README.md +1 -1
  15. package/extensions/run-summary/index.ts +18 -25
  16. package/extensions/runtime-context/README.md +1 -1
  17. package/extensions/runtime-context/context.ts +1 -1
  18. package/extensions/runtime-context/index.ts +10 -16
  19. package/extensions/silent-command-runner/README.md +0 -2
  20. package/extensions/silent-command-runner/index.ts +12 -41
  21. package/extensions/soul/README.md +5 -8
  22. package/extensions/soul/context.ts +115 -0
  23. package/extensions/soul/index.ts +141 -7
  24. package/extensions/soul/prompt.ts +47 -102
  25. package/extensions/soul/state.ts +114 -0
  26. package/extensions/soul/tools.ts +31 -0
  27. package/extensions/tau-help/help.md +6 -18
  28. package/extensions/tau-help/index.ts +10 -4
  29. package/extensions/tool-approval/README.md +2 -0
  30. package/extensions/tool-approval/index.ts +65 -44
  31. package/extensions/tool-approval/panel.ts +153 -0
  32. package/extensions/tool-loader/README.md +1 -1
  33. package/extensions/tool-loader/index.ts +91 -22
  34. package/package.json +2 -2
  35. package/schemas/tau.schema.json +0 -104
  36. package/shared/bounded-text-result.ts +0 -1
  37. package/shared/events.ts +19 -15
  38. package/shared/isolated-session.ts +1 -2
  39. package/shared/model-effort.ts +15 -21
  40. package/shared/prompt-contributions.ts +24 -0
  41. package/src/index.ts +1 -1
  42. package/docs/subagents.md +0 -92
  43. package/extensions/auto-compact/README.md +0 -9
  44. package/extensions/auto-compact/index.ts +0 -122
  45. package/extensions/auto-compact/settings.ts +0 -30
  46. package/extensions/context/settings.ts +0 -62
  47. package/extensions/context/sync.ts +0 -276
  48. package/extensions/context/validation.ts +0 -88
  49. package/extensions/effort/README.md +0 -7
  50. package/extensions/effort/index.ts +0 -134
  51. package/extensions/effort/state.ts +0 -18
  52. package/extensions/qna/inline-editor-row.ts +0 -56
  53. package/extensions/soul/overseer.ts +0 -273
  54. package/extensions/soul/settings.ts +0 -34
  55. package/extensions/subagent/README.md +0 -67
  56. package/extensions/subagent/agents/context-sync.md +0 -244
  57. package/extensions/subagent/agents/dormant/generalist.md +0 -33
  58. package/extensions/subagent/agents/scout.md +0 -104
  59. package/extensions/subagent/agents/web-research.md +0 -101
  60. package/extensions/subagent/agents.ts +0 -261
  61. package/extensions/subagent/cmux-dashboard.ts +0 -495
  62. package/extensions/subagent/index.ts +0 -425
  63. package/extensions/subagent/panel.ts +0 -124
  64. package/extensions/subagent/render.ts +0 -74
  65. package/extensions/subagent/resume.ts +0 -78
  66. package/extensions/subagent/run.ts +0 -641
  67. package/extensions/subagent/runtime.ts +0 -1296
  68. package/extensions/subagent/session-resource.ts +0 -61
  69. package/extensions/subagent/settings.ts +0 -18
@@ -0,0 +1,114 @@
1
+ import type { Tool } from "@earendil-works/pi-ai";
2
+ import type { ContextWithSystemEvent, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import type { PromptValue } from "../../shared/prompt-contributions.ts";
4
+
5
+ export const BASELINE_TYPE = "tau.soul.baseline";
6
+ export const UPDATE_TYPE = "tau.soul.update";
7
+
8
+ export interface SavedBaseline {
9
+ version: 1;
10
+ compactionId: string | null;
11
+ afterEntryId: string;
12
+ text: string;
13
+ values: PromptValue[];
14
+ initialTools: Tool[];
15
+ }
16
+
17
+ export interface SavedUpdate {
18
+ version: 1;
19
+ baselineEntryId: string;
20
+ afterEntryId: string;
21
+ text: string;
22
+ values: PromptValue[];
23
+ tools: Tool[];
24
+ }
25
+
26
+ interface ActivePrompt {
27
+ entryId: string;
28
+ baseline: SavedBaseline;
29
+ updates: Array<{ timestamp: number; data: SavedUpdate }>;
30
+ }
31
+
32
+ export function restorePrompt(branch: readonly SessionEntry[]): ActivePrompt | null {
33
+ const newestFirst = [...branch].reverse();
34
+ const compaction = newestFirst.find((entry) => entry.type === "compaction");
35
+ const entry = newestFirst.find((item) => item.type === "custom" && item.customType === BASELINE_TYPE);
36
+ if (entry?.type !== "custom") return null;
37
+ const baseline = entry.data as SavedBaseline;
38
+ if (baseline?.version !== 1 || typeof baseline.text !== "string" || !Array.isArray(baseline.values)) {
39
+ throw new Error("Invalid saved Soul baseline.");
40
+ }
41
+ if (baseline.compactionId !== (compaction?.id ?? null)) return null;
42
+ const updates: ActivePrompt["updates"] = [];
43
+ for (const item of branch) {
44
+ if (item.type !== "custom" || item.customType !== UPDATE_TYPE) continue;
45
+ const data = item.data as SavedUpdate;
46
+ if (data?.version !== 1 || typeof data.text !== "string" || !Array.isArray(data.values)) {
47
+ throw new Error("Invalid saved Soul update.");
48
+ }
49
+ if (data.baselineEntryId === entry.id) updates.push({ timestamp: Date.parse(item.timestamp), data });
50
+ }
51
+ return { entryId: entry.id, baseline, updates };
52
+ }
53
+
54
+ /** Rebuild only Soul's text; tool declarations retain their original transcript positions. */
55
+ export function projectPrompt(
56
+ messages: ContextWithSystemEvent["messages"],
57
+ saved: ActivePrompt,
58
+ ctx: ExtensionContext,
59
+ ): ContextWithSystemEvent["messages"] {
60
+ const projection = ctx.sessionManager.buildSessionProjection();
61
+ // Pi clones the hook input. Compare content, never object identity. A rewrite from
62
+ // another extension must not silently move a saved update or tool declaration.
63
+ if (JSON.stringify(messages) !== JSON.stringify(projection.messages)) {
64
+ throw new Error("Soul cannot preserve the prompt prefix: another extension changed request history.");
65
+ }
66
+ const output: ContextWithSystemEvent["messages"] = [
67
+ {
68
+ role: "system",
69
+ content: saved.baseline.text,
70
+ toolsAdded: saved.baseline.initialTools,
71
+ timestamp: 0,
72
+ },
73
+ ];
74
+ let pastBaseline = false;
75
+ const placed = new Set<SavedUpdate>();
76
+ const admitted = new Set(saved.baseline.initialTools.map((tool) => tool.name));
77
+ for (const entry of projection.entries) {
78
+ for (const message of entry.messages) {
79
+ if (message.role !== "system") {
80
+ output.push(message);
81
+ continue;
82
+ }
83
+ if (!pastBaseline) continue;
84
+ if (typeof message.content === "string" ? message.content.trim() : message.content.length > 0) {
85
+ throw new Error("A later system instruction bypassed Soul's prompt contributions.");
86
+ }
87
+ // Removed tools remain declared for cache stability; Pi still controls execution.
88
+ const added = (message.toolsAdded ?? []).filter((tool) => !admitted.has(tool.name));
89
+ for (const tool of added) admitted.add(tool.name);
90
+ if (added.length > 0)
91
+ output.push({ role: "system", content: "", toolsAdded: added, timestamp: message.timestamp });
92
+ }
93
+ if (entry.sourceEntry.id === saved.baseline.afterEntryId) pastBaseline = true;
94
+ for (const update of saved.updates) {
95
+ if (update.data.afterEntryId !== entry.sourceEntry.id) continue;
96
+ if (update.data.text)
97
+ output.push({
98
+ role: "custom",
99
+ customType: UPDATE_TYPE,
100
+ content: update.data.text,
101
+ display: false,
102
+ timestamp: update.timestamp,
103
+ });
104
+ placed.add(update.data);
105
+ }
106
+ }
107
+ if (!pastBaseline || placed.size !== saved.updates.length)
108
+ throw new Error("Soul prompt history lost a saved anchor.");
109
+ return output;
110
+ }
111
+
112
+ export function admittedTools(saved: ActivePrompt): Tool[] {
113
+ return saved.updates.at(-1)?.data.tools ?? saved.baseline.initialTools;
114
+ }
@@ -0,0 +1,31 @@
1
+ import { declarationsEqual, type Api, type Model, type Tool } from "@earendil-works/pi-ai";
2
+
3
+ /** Removals only disable execution; changed declarations wait for compaction. */
4
+ export function toolChangeReason(
5
+ model: Model<Api>,
6
+ previous: readonly Tool[],
7
+ requested: readonly Tool[],
8
+ ): string | null {
9
+ const existing = new Map(previous.map((tool) => [tool.name, tool]));
10
+ for (const tool of requested) {
11
+ const old = existing.get(tool.name);
12
+ if (old && !declarationsEqual(old, tool))
13
+ return `Tool ${tool.name} changed its schema or description. Compact before loading it.`;
14
+ }
15
+ if (requested.every((tool) => existing.has(tool.name))) return null;
16
+ const compat = model.compat;
17
+ const supported =
18
+ compat &&
19
+ "supportsMidConvoSystemMessages" in compat &&
20
+ compat.supportsMidConvoSystemMessages === true &&
21
+ ((model.api === "anthropic-messages" &&
22
+ "supportsMidConvoToolChanges" in compat &&
23
+ compat.supportsMidConvoToolChanges === true &&
24
+ previous.length > 0) ||
25
+ ((model.api === "openai-responses" || model.api === "openai-codex-responses") &&
26
+ (("supportsAdditionalTools" in compat && compat.supportsAdditionalTools === true) ||
27
+ ("supportsToolSearch" in compat && compat.supportsToolSearch === true))));
28
+ return supported
29
+ ? null
30
+ : "This model cannot add tools without changing the cached prefix. Compact before loading them.";
31
+ }
@@ -12,16 +12,12 @@ Adds `/aside <question>` for a one-off question to the current model without put
12
12
 
13
13
  ## attention
14
14
 
15
- Shows attention state when Tau needs the user to look at the chat, finishes a manual compaction, or summarizes an abandoned branch. Automatic compaction stays quiet until its resumed work settles.
15
+ Shows attention state when Tau needs the user to look at the chat, finishes a compaction, or summarizes an abandoned branch.
16
16
 
17
17
  ## auto-name
18
18
 
19
19
  Names sessions from their first request so saved sessions remain findable.
20
20
 
21
- ## auto-compact
22
-
23
- Uses Pi's native compaction before a model turn when the current context reaches `extensions.autoCompact.tokenLimit`, which defaults to 175,000 tokens for every model. Set `extensions.autoCompact.enabled` to `false` to disable it. Interrupted work resumes through a hidden continuation message without an attention alert until the resumed work settles. Pi's native collapsed compaction entry remains visible in chat.
24
-
25
21
  ## branch
26
22
 
27
23
  Adds `/branch` to create and switch Git branches from the TUI. Switching
@@ -46,11 +42,7 @@ Adds `/cost-report` to build an HTML spend report from local session usage. Pick
46
42
 
47
43
  ## context
48
44
 
49
- Adds `/context` to inject reusable repository work scopes from `.pi/contexts`, and `/context-sync` or `/context-sync <nudge>` for human-driven catalog sync. 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. Manual sync replaces the editor with a status panel; Escape or Ctrl+C cancels. When `sync.automation` is on, coding agent can also run `context-sync` after meaningful uncommitted work. Sync catalogs durable code and long-lived documentation; recurring scratch, planning, interview, and rough-idea paths belong in `validation.ignoreGlobs`. `sync.enabled` is master switch for command, automation, and validation auto-run. Context validation is off by default; when on (and sync enabled), Tau auto-runs context-sync on failure. 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.
50
-
51
- ## effort
52
-
53
- Adds `/effort [quick|standard|deep]` to select effort and a provider from current logins. Tau selects provider’s best available model for tier, then tries its configured model fallback. `Ctrl+Shift+E` cycles tiers on current provider. Footer derives effort from current provider, model, and thinking level, and hides it when no configured tier matches.
45
+ 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.
54
46
 
55
47
  ## explore
56
48
 
@@ -102,7 +94,7 @@ Shows a compact display-only marker after each run with wall time and model cost
102
94
 
103
95
  ## runtime-context
104
96
 
105
- Supplies the agent with the current local date and an initial root directory snapshot as hidden session context.
97
+ Supplies Soul with the local date and root directory snapshot. Both remain fixed across turns, reload, and resume, and refresh after successful compaction.
106
98
 
107
99
  ## script-runner
108
100
 
@@ -114,16 +106,12 @@ Runs configured commands while keeping their output out of agent context when th
114
106
 
115
107
  ## soul
116
108
 
117
- Adds the baseline Tau system prompt on every session: communication style, operating model, and code style. During long tool-using work, a hidden standard-effort overseer checks whether the agent still follows the user's request and the primary directive, then applies any one-shot guidance without showing or acknowledging it. Set `extensions.soul.overseer.enabled` to turn this check on or off and `extensions.soul.overseer.toolCallInterval` to change the default 20-tool interval.
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.
118
110
 
119
111
  ## stash
120
112
 
121
113
  Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed drafts and put one back in the editor.
122
114
 
123
- ## subagent
124
-
125
- Gives Tau a subagent delegation tool for isolated, focused work. Run `/agents` to enable or disable individual agents for the current session, or set `extensions.subagent.disabled` in Tau settings for a persistent choice. `scout` is substantial multi-hop local code lookup that would chew parent context; facts only, not small digs; `web-research` handles external research. Known files can be autoread as line-numbered snapshots into a fresh or retained child turn. Tau can continue a retained child thread when follow-up work depends on its prior reads and reasoning. You can also create your own subagents in supported subagent directories. Ask Tau how to do it and have it consult extension documentation; built-in agents show pattern. Each subagent can register its own model, tools, and pool of display names. Reused pool names get numeric suffixes. In interactive cmux sessions, Tau opens one temporary Markdown dashboard for live subagent progress; it does not change how children run and closes shortly after active cohort finishes.
126
-
127
115
  ## tau-help
128
116
 
129
117
  Adds `/tau-help` to show this guide as rendered Markdown in the chat.
@@ -134,11 +122,11 @@ Adds `/tau`, `/tau init [--global|--project]`, and `/tau doctor` for Tau setup a
134
122
 
135
123
  ## tool-approval
136
124
 
137
- Reviews agent `bash` and `script_runner` requests before they run. Common read-only bash commands skip review. Set `extensions.toolApproval.autoApprove` to run every reviewer-approved request without another confirmation. Those auto-approvals show a user-only marker. The reviewer approves routine local development work. Concrete destructive, system, production, privileged, or security-sensitive effects require human approval with one explanatory paragraph. Reviewer failures fall back to human approval and send an attention notification.
125
+ Reviews agent `bash` and `script_runner` requests before they run. Common read-only bash commands skip review. Set `extensions.toolApproval.autoApprove` to run every reviewer-approved request without another confirmation. Those auto-approvals show a user-only marker. The reviewer approves routine local development work. Concrete destructive, system, production, privileged, or security-sensitive effects require human approval with one explanatory paragraph. Reviewer failures fall back to human approval and send an attention notification. In the terminal approval panel, press `n` to add a note to Approve or Reject before choosing. Rejection notes tell the agent why the request was blocked; approval notes reach it with the tool result without changing the request. Reject with a note to ask for a revised request.
138
126
 
139
127
  ## tool-loader
140
128
 
141
- 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`. Supported providers can preserve more prompt-cache reuse.
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.
142
130
 
143
131
  ## web
144
132
 
@@ -3,11 +3,12 @@ import { readFile } from "node:fs/promises";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { Markdown, type Component, visibleWidth } from "@earendil-works/pi-tui";
6
+ import { registerPromptSource } from "../../shared/prompt-contributions.ts";
6
7
 
7
8
  const TAU_DOCS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs");
8
9
  const TAU_DOCS_GUIDANCE = `Tau Agent documentation (read only when the user asks about Tau Agent, Rok, Tau extensions, Tau event APIs, harness behavior, or extending Tau Agent):
9
10
  - Tau Agent docs: ${TAU_DOCS_PATH}
10
- - When asked about: context management / .pi/contexts taxonomy (docs/context.md), public events / external integration (docs/extending-tau-agent.md), custom subagents (docs/subagents.md), Tau TUI components (docs/tui.md)
11
+ - When asked about: context management / .pi/contexts taxonomy (docs/context.md), public events / external integration (docs/extending-tau-agent.md), Tau TUI components (docs/tui.md)
11
12
  - Resolve Tau docs/... under Tau Agent docs, not the current working directory
12
13
  - When working on Tau topics, read the docs and follow .md cross-references before implementing
13
14
  - Do not read Tau Agent docs for normal coding tasks`;
@@ -54,9 +55,14 @@ class TauHelpMessage implements Component {
54
55
  }
55
56
 
56
57
  export default function tauHelpExtension(pi: ExtensionAPI): void {
57
- pi.on("before_agent_start", (event) => ({
58
- systemPrompt: `${event.systemPrompt}\n\n${TAU_DOCS_GUIDANCE}`,
59
- }));
58
+ registerPromptSource(pi, {
59
+ key: "tau/documentation",
60
+ section: "documentation",
61
+ refresh: "compaction",
62
+ async read() {
63
+ return TAU_DOCS_GUIDANCE;
64
+ },
65
+ });
60
66
 
61
67
  pi.registerMessageRenderer("tau-help", (message, _options, _theme) => {
62
68
  if (typeof message.content !== "string") return undefined;
@@ -8,6 +8,8 @@ With `autoApprove` enabled, reviewer-approved requests run without another confi
8
8
 
9
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.
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.
12
+
11
13
  Configure under `extensions.toolApproval`:
12
14
 
13
15
  ```json
@@ -12,6 +12,7 @@ import { generateToolValidated, resolveCandidates } from "../../shared/model-fal
12
12
  import { errorText, truncAt } from "../../shared/text.ts";
13
13
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
14
14
  import { isAllowlistedBash } from "./allowlist.ts";
15
+ import { ToolApprovalPanel, type ApprovalAnswer } from "./panel.ts";
15
16
  import toolApprovalSettings from "./settings.ts";
16
17
 
17
18
  const STATUS_KEY = "tool-approval";
@@ -56,12 +57,12 @@ const REVIEW_TOOL = {
56
57
  } satisfies Tool;
57
58
 
58
59
  const REVIEW_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
59
- { provider: "openai", model: "gpt-5.6-luna", reasoning: "medium" },
60
- { provider: "openai-codex", model: "gpt-5.6-luna", reasoning: "medium" },
60
+ { provider: "openai", model: "gpt-6-luna", reasoning: "medium" },
61
+ { provider: "openai-codex", model: "gpt-6-luna", reasoning: "medium" },
61
62
  { provider: "anthropic", model: "claude-sonnet-5", reasoning: "medium" },
62
63
  { provider: "xai", model: "grok-4.5", reasoning: "low" },
63
- { provider: "openrouter", model: "deepseek/deepseek-v4.1-flash", reasoning: "low" },
64
- { provider: "opencode-go", model: "deepseek-v4.1-flash", reasoning: "low" },
64
+ { provider: "openrouter", model: "deepseek/deepseek-v4.1-flash", reasoning: "high" },
65
+ { provider: "opencode-go", model: "deepseek-v4.1-flash", reasoning: "high" },
65
66
  ];
66
67
 
67
68
  type ToolReview =
@@ -82,6 +83,7 @@ interface AutoApprovedMarker {
82
83
 
83
84
  export default function toolApprovalExtension(pi: ExtensionAPI): void {
84
85
  let settings = toolApprovalSettings.defaults;
86
+ const pendingNotes = new Map<string, string>();
85
87
 
86
88
  pi.registerEntryRenderer<AutoApprovedMarker>(AUTO_APPROVED_TYPE, (entry, _options, theme) => {
87
89
  const marker = autoApprovedMarker(entry.data);
@@ -98,22 +100,44 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
98
100
  settings = await loadTauExtensionSettings(ctx, toolApprovalSettings);
99
101
  }
100
102
 
101
- pi.on("session_start", async (_event, ctx) => {
102
- await refreshSettings(ctx);
103
- });
103
+ async function requestToolApproval(
104
+ ctx: ExtensionContext,
105
+ toolCallId: string,
106
+ toolName: ApprovalToolName,
107
+ title: string,
108
+ body: string,
109
+ ): Promise<{ block: true; reason: string } | undefined> {
110
+ if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
111
+ try {
112
+ emitAgentBlocked(pi, {
113
+ title: "Tool request review",
114
+ body: `Waiting for ${toolLabel(toolName)} approval`,
115
+ source: "tool-approval.review",
116
+ });
117
+ if (ctx.mode !== "tui") {
118
+ const confirmed = await ctx.ui.confirm(title, body);
119
+ return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
120
+ }
121
+ const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
122
+ (tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, done),
123
+ );
124
+ if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
125
+ const note = truncAt(answer.note, 800);
126
+ if (answer.choice === "reject") {
127
+ return block(`${toolLabel(toolName)} rejected by user${note ? `. User note: ${note}` : ""}`);
128
+ }
129
+ if (note) pendingNotes.set(toolCallId, note);
130
+ return undefined;
131
+ } catch (error) {
132
+ const message = singleLine(errorText(error));
133
+ ctx.ui.notify(`Tool approval failed; request blocked: ${truncAt(message, 600)}`, "error");
134
+ return block(`tool approval failed: ${truncAt(message, 600)}`);
135
+ }
136
+ }
104
137
 
105
- pi.on("before_agent_start", async (event, ctx) => {
138
+ pi.on("session_start", async (_event, ctx) => {
139
+ pendingNotes.clear();
106
140
  await refreshSettings(ctx);
107
- if (!settings.enabled) return undefined;
108
- return {
109
- systemPrompt: `${event.systemPrompt}\n\n${[
110
- "Known-safe read-only bash commands skip review.",
111
- "Other bash and every script_runner request are reviewed by a separate safety classifier before execution.",
112
- "Treat classifier approval as a gate, not as permission to hide command intent from the user.",
113
- "Routine local development requests can be approved automatically.",
114
- "Requests with destructive, system, production, privileged, or security-sensitive effects require human confirmation.",
115
- ].join("\n")}`,
116
- };
117
141
  });
118
142
 
119
143
  pi.on("tool_call", async (event, ctx) => {
@@ -145,8 +169,8 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
145
169
  const { review, provider, model } = await reviewToolRequest(ctx, request);
146
170
  if (review.decision === "requires_user_approval") {
147
171
  return requestToolApproval(
148
- pi,
149
172
  ctx,
173
+ event.toolCallId,
150
174
  request.toolName,
151
175
  `Approve high-impact ${toolLabel(request.toolName)}?`,
152
176
  formatApproval(review.summary, review.reason),
@@ -161,8 +185,8 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
161
185
  return undefined;
162
186
  }
163
187
  return requestToolApproval(
164
- pi,
165
188
  ctx,
189
+ event.toolCallId,
166
190
  request.toolName,
167
191
  `Run reviewed ${toolLabel(request.toolName)}?`,
168
192
  formatApproval(review.summary, "Automatic approval is disabled."),
@@ -171,8 +195,8 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
171
195
  const message = singleLine(errorText(error));
172
196
  ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
173
197
  return requestToolApproval(
174
- pi,
175
198
  ctx,
199
+ event.toolCallId,
176
200
  request.toolName,
177
201
  `Automatic ${toolLabel(request.toolName)} review failed. Continue?`,
178
202
  `The automatic review failed, so Tau could not summarize this ${toolLabel(request.toolName)}. Approve it only if you understand the request shown above.`,
@@ -182,7 +206,27 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
182
206
  }
183
207
  });
184
208
 
209
+ pi.on("tool_result", (event) => {
210
+ const note = pendingNotes.get(event.toolCallId);
211
+ if (!note) return;
212
+ pendingNotes.delete(event.toolCallId);
213
+ return {
214
+ content: [
215
+ ...event.content,
216
+ {
217
+ type: "text" as const,
218
+ text: `User approval note (guidance for subsequent actions; does not change this tool request):\n${note}`,
219
+ },
220
+ ],
221
+ };
222
+ });
223
+
224
+ pi.on("agent_end", () => {
225
+ pendingNotes.clear();
226
+ });
227
+
185
228
  pi.on("session_shutdown", (_event, ctx) => {
229
+ pendingNotes.clear();
186
230
  ctx.ui.setStatus(STATUS_KEY, undefined);
187
231
  });
188
232
  }
@@ -269,29 +313,6 @@ function formatApproval(summary: string, reason: string): string {
269
313
  return singleLine(`${summary} ${reason}`);
270
314
  }
271
315
 
272
- async function requestToolApproval(
273
- pi: Pick<ExtensionAPI, "events">,
274
- ctx: ExtensionContext,
275
- toolName: ApprovalToolName,
276
- title: string,
277
- body: string,
278
- ): Promise<{ block: true; reason: string } | undefined> {
279
- if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
280
- try {
281
- emitAgentBlocked(pi, {
282
- title: "Tool request review",
283
- body: `Waiting for ${toolLabel(toolName)} approval`,
284
- source: "tool-approval.review",
285
- });
286
- const confirmed = await ctx.ui.confirm(title, body);
287
- return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
288
- } catch (error) {
289
- const message = singleLine(errorText(error));
290
- ctx.ui.notify(`Tool approval failed; request blocked: ${truncAt(message, 600)}`, "error");
291
- return block(`tool approval failed: ${truncAt(message, 600)}`);
292
- }
293
- }
294
-
295
316
  function singleLine(text: string): string {
296
317
  return text.replaceAll(/\s+/g, " ").trim();
297
318
  }
@@ -0,0 +1,153 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Editor,
5
+ type Focusable,
6
+ Key,
7
+ type KeybindingsManager,
8
+ matchesKey,
9
+ type TUI,
10
+ } from "@earendil-works/pi-tui";
11
+ import {
12
+ bindingHint,
13
+ bindingsHint,
14
+ editorTheme,
15
+ pushSavedNote,
16
+ rawHint,
17
+ renderNoteEditor,
18
+ ToolPanel,
19
+ type ToolPanelConfig,
20
+ wrapWithPrefix,
21
+ } from "@shanepadgett/tau-tui";
22
+
23
+ export type ApprovalChoice = "approve" | "reject";
24
+ export interface ApprovalAnswer {
25
+ choice: ApprovalChoice;
26
+ note: string;
27
+ }
28
+
29
+ export class ToolApprovalPanel implements Component, Focusable {
30
+ private readonly tui: TUI;
31
+ private readonly theme: Theme;
32
+ private readonly keys: KeybindingsManager;
33
+ private readonly done: (answer: ApprovalAnswer | undefined) => void;
34
+ private readonly noteEditor: Editor;
35
+ private readonly panelConfig: ToolPanelConfig;
36
+ private readonly panel: ToolPanel;
37
+ private readonly notes: Record<ApprovalChoice, string> = { approve: "", reject: "" };
38
+ private choice: ApprovalChoice = "approve";
39
+ private editing = false;
40
+ private _focused = false;
41
+
42
+ constructor(
43
+ tui: TUI,
44
+ theme: Theme,
45
+ keys: KeybindingsManager,
46
+ title: string,
47
+ body: string,
48
+ done: (answer: ApprovalAnswer | undefined) => void,
49
+ ) {
50
+ this.tui = tui;
51
+ this.theme = theme;
52
+ this.keys = keys;
53
+ this.done = done;
54
+ this.noteEditor = new Editor(tui, editorTheme(theme));
55
+ this.noteEditor.onSubmit = (value) => {
56
+ this.notes[this.choice] = value.trim();
57
+ this.closeNote();
58
+ };
59
+ this.panelConfig = {
60
+ title,
61
+ secondary: "Approve runs this request as shown. To change it, reject with a note.",
62
+ header: [body],
63
+ body: { render: (width) => this.renderChoices(width), invalidate: () => {} },
64
+ footer: { kind: "hints", hints: this.hints() },
65
+ };
66
+ this.panel = new ToolPanel(theme, this.panelConfig);
67
+ }
68
+
69
+ get focused(): boolean {
70
+ return this._focused;
71
+ }
72
+
73
+ set focused(value: boolean) {
74
+ this._focused = value;
75
+ this.noteEditor.focused = value && this.editing;
76
+ }
77
+
78
+ handleInput(data: string): void {
79
+ if (matchesKey(data, Key.ctrl("c"))) {
80
+ this.done(undefined);
81
+ return;
82
+ }
83
+ if (this.editing) {
84
+ if (this.keys.matches(data, "tui.select.cancel")) this.closeNote();
85
+ else this.noteEditor.handleInput(data);
86
+ this.refresh();
87
+ return;
88
+ }
89
+ if (this.keys.matches(data, "tui.select.cancel")) {
90
+ this.done(undefined);
91
+ return;
92
+ }
93
+ if (this.keys.matches(data, "tui.select.up") || this.keys.matches(data, "tui.select.down")) {
94
+ this.choice = this.choice === "approve" ? "reject" : "approve";
95
+ } else if (data === "n") {
96
+ this.editing = true;
97
+ this.noteEditor.setText(this.notes[this.choice]);
98
+ this.focused = this._focused;
99
+ } else if (this.keys.matches(data, "tui.select.confirm")) {
100
+ this.done({ choice: this.choice, note: this.notes[this.choice] });
101
+ return;
102
+ }
103
+ this.refresh();
104
+ }
105
+
106
+ render(width: number): string[] {
107
+ return this.panel.render(width);
108
+ }
109
+
110
+ invalidate(): void {
111
+ this.panel.invalidate();
112
+ }
113
+
114
+ private renderChoices(width: number): string[] {
115
+ const lines: string[] = [];
116
+ for (const choice of ["approve", "reject"] as const) {
117
+ const selected = choice === this.choice;
118
+ const prefix = selected ? this.theme.fg("accent", "→ ") : " ";
119
+ lines.push(
120
+ ...wrapWithPrefix(
121
+ prefix,
122
+ this.theme.fg(selected ? "accent" : "text", choice === "approve" ? "Approve" : "Reject"),
123
+ width,
124
+ ),
125
+ );
126
+ if (this.editing && selected) renderNoteEditor(lines, this.noteEditor, width, this.theme, " ");
127
+ else if (this.notes[choice]) pushSavedNote(lines, this.notes[choice], width, this.theme, " ");
128
+ }
129
+ return lines;
130
+ }
131
+
132
+ private closeNote(): void {
133
+ this.editing = false;
134
+ this.noteEditor.setText("");
135
+ this.focused = this._focused;
136
+ }
137
+
138
+ private hints() {
139
+ return this.editing
140
+ ? [bindingHint("tui.input.submit", "save"), bindingHint("tui.select.cancel", "cancel note")]
141
+ : [
142
+ bindingsHint(["tui.select.up", "tui.select.down"], "move"),
143
+ bindingHint("tui.select.confirm", "choose"),
144
+ rawHint("n", "note"),
145
+ bindingHint("tui.select.cancel", "block"),
146
+ ];
147
+ }
148
+
149
+ private refresh(): void {
150
+ this.panelConfig.footer = { kind: "hints", hints: this.hints() };
151
+ this.tui.requestRender();
152
+ }
153
+ }
@@ -10,6 +10,6 @@ The agent normally calls `load_tools` itself. Tau's built-in groups are:
10
10
 
11
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.
12
12
 
13
- Supported models optimize prompt caching when a group loads. Other models keep the same functional behavior.
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.
14
14
 
15
15
  After changing this extension during development, run `/reload` before testing.