@shanepadgett/tau-agent 0.42.3 → 0.44.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 (70) 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/script-runner/index.ts +21 -29
  20. package/extensions/silent-command-runner/README.md +0 -2
  21. package/extensions/silent-command-runner/index.ts +12 -41
  22. package/extensions/soul/README.md +5 -8
  23. package/extensions/soul/context.ts +115 -0
  24. package/extensions/soul/index.ts +141 -7
  25. package/extensions/soul/prompt.ts +47 -102
  26. package/extensions/soul/state.ts +114 -0
  27. package/extensions/soul/tools.ts +31 -0
  28. package/extensions/tau-help/help.md +6 -18
  29. package/extensions/tau-help/index.ts +10 -4
  30. package/extensions/tool-approval/README.md +4 -2
  31. package/extensions/tool-approval/index.ts +141 -53
  32. package/extensions/tool-approval/panel.ts +153 -0
  33. package/extensions/tool-loader/README.md +1 -1
  34. package/extensions/tool-loader/index.ts +91 -22
  35. package/package.json +2 -2
  36. package/schemas/tau.schema.json +0 -104
  37. package/shared/bounded-text-result.ts +0 -1
  38. package/shared/events.ts +19 -15
  39. package/shared/isolated-session.ts +1 -2
  40. package/shared/model-effort.ts +15 -21
  41. package/shared/prompt-contributions.ts +24 -0
  42. package/src/index.ts +1 -1
  43. package/docs/subagents.md +0 -92
  44. package/extensions/auto-compact/README.md +0 -9
  45. package/extensions/auto-compact/index.ts +0 -122
  46. package/extensions/auto-compact/settings.ts +0 -30
  47. package/extensions/context/settings.ts +0 -62
  48. package/extensions/context/sync.ts +0 -276
  49. package/extensions/context/validation.ts +0 -88
  50. package/extensions/effort/README.md +0 -7
  51. package/extensions/effort/index.ts +0 -134
  52. package/extensions/effort/state.ts +0 -18
  53. package/extensions/qna/inline-editor-row.ts +0 -56
  54. package/extensions/soul/overseer.ts +0 -273
  55. package/extensions/soul/settings.ts +0 -34
  56. package/extensions/subagent/README.md +0 -67
  57. package/extensions/subagent/agents/context-sync.md +0 -244
  58. package/extensions/subagent/agents/dormant/generalist.md +0 -33
  59. package/extensions/subagent/agents/scout.md +0 -104
  60. package/extensions/subagent/agents/web-research.md +0 -101
  61. package/extensions/subagent/agents.ts +0 -261
  62. package/extensions/subagent/cmux-dashboard.ts +0 -495
  63. package/extensions/subagent/index.ts +0 -425
  64. package/extensions/subagent/panel.ts +0 -124
  65. package/extensions/subagent/render.ts +0 -74
  66. package/extensions/subagent/resume.ts +0 -78
  67. package/extensions/subagent/run.ts +0 -641
  68. package/extensions/subagent/runtime.ts +0 -1296
  69. package/extensions/subagent/session-resource.ts +0 -61
  70. package/extensions/subagent/settings.ts +0 -18
@@ -12,10 +12,12 @@ 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";
18
19
  const AUTO_APPROVED_TYPE = "tau.tool-approval.auto-approved";
20
+ const MAX_CONCURRENT_REVIEWS = 3;
19
21
 
20
22
  const REVIEW_SCHEMA = Type.Object(
21
23
  {
@@ -56,12 +58,12 @@ const REVIEW_TOOL = {
56
58
  } satisfies Tool;
57
59
 
58
60
  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" },
61
+ { provider: "openai", model: "gpt-6-luna", reasoning: "medium" },
62
+ { provider: "openai-codex", model: "gpt-6-luna", reasoning: "medium" },
61
63
  { provider: "anthropic", model: "claude-sonnet-5", reasoning: "medium" },
62
64
  { 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" },
65
+ { provider: "openrouter", model: "deepseek/deepseek-v4.1-flash", reasoning: "high" },
66
+ { provider: "opencode-go", model: "deepseek-v4.1-flash", reasoning: "high" },
65
67
  ];
66
68
 
67
69
  type ToolReview =
@@ -74,6 +76,13 @@ interface ToolApprovalRequest {
74
76
  input: Record<string, unknown>;
75
77
  }
76
78
 
79
+ type ToolReviewResult = { review: ToolReview; provider: string; model: string };
80
+
81
+ interface CachedReview {
82
+ requestJson: string;
83
+ outcome: PromiseSettledResult<ToolReviewResult>;
84
+ }
85
+
77
86
  interface AutoApprovedMarker {
78
87
  toolName: ApprovalToolName;
79
88
  provider: string;
@@ -82,6 +91,8 @@ interface AutoApprovedMarker {
82
91
 
83
92
  export default function toolApprovalExtension(pi: ExtensionAPI): void {
84
93
  let settings = toolApprovalSettings.defaults;
94
+ let batchReviews: Map<string, CachedReview> | undefined;
95
+ const pendingNotes = new Map<string, string>();
85
96
 
86
97
  pi.registerEntryRenderer<AutoApprovedMarker>(AUTO_APPROVED_TYPE, (entry, _options, theme) => {
87
98
  const marker = autoApprovedMarker(entry.data);
@@ -98,22 +109,45 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
98
109
  settings = await loadTauExtensionSettings(ctx, toolApprovalSettings);
99
110
  }
100
111
 
101
- pi.on("session_start", async (_event, ctx) => {
102
- await refreshSettings(ctx);
103
- });
112
+ async function requestToolApproval(
113
+ ctx: ExtensionContext,
114
+ toolCallId: string,
115
+ toolName: ApprovalToolName,
116
+ title: string,
117
+ body: string,
118
+ ): Promise<{ block: true; reason: string } | undefined> {
119
+ if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
120
+ try {
121
+ emitAgentBlocked(pi, {
122
+ title: "Tool request review",
123
+ body: `Waiting for ${toolLabel(toolName)} approval`,
124
+ source: "tool-approval.review",
125
+ });
126
+ if (ctx.mode !== "tui") {
127
+ const confirmed = await ctx.ui.confirm(title, body);
128
+ return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
129
+ }
130
+ const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
131
+ (tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, done),
132
+ );
133
+ if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
134
+ const note = truncAt(answer.note, 800);
135
+ if (answer.choice === "reject") {
136
+ return block(`${toolLabel(toolName)} rejected by user${note ? `. User note: ${note}` : ""}`);
137
+ }
138
+ if (note) pendingNotes.set(toolCallId, note);
139
+ return undefined;
140
+ } catch (error) {
141
+ const message = singleLine(errorText(error));
142
+ ctx.ui.notify(`Tool approval failed; request blocked: ${truncAt(message, 600)}`, "error");
143
+ return block(`tool approval failed: ${truncAt(message, 600)}`);
144
+ }
145
+ }
104
146
 
105
- pi.on("before_agent_start", async (event, ctx) => {
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ batchReviews = undefined;
149
+ pendingNotes.clear();
106
150
  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
151
  });
118
152
 
119
153
  pi.on("tool_call", async (event, ctx) => {
@@ -128,25 +162,36 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
128
162
  }
129
163
  if (!settings.enabled) return undefined;
130
164
 
131
- let command: string | undefined;
132
165
  if (request.toolName === "bash") {
133
- const value = request.input.command;
134
- if (typeof value !== "string") return block("bash command was malformed");
135
- if (!value.trim()) {
166
+ const command = request.input.command;
167
+ if (typeof command !== "string") return block("bash command was malformed");
168
+ if (!command.trim()) {
136
169
  ctx.ui.notify("Bash command blocked: command is empty", "warning");
137
170
  return block("bash command is empty");
138
171
  }
139
- command = value;
140
172
  if (isAllowlistedBash(command)) return undefined;
141
173
  }
142
174
 
143
175
  ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
144
176
  try {
145
- const { review, provider, model } = await reviewToolRequest(ctx, request);
177
+ batchReviews ??= await reviewAssistantRequests(ctx, event, request);
178
+ if (ctx.signal?.aborted) return block("Tool review cancelled");
179
+ const cached = batchReviews.get(event.toolCallId);
180
+ batchReviews.delete(event.toolCallId);
181
+ let result: ToolReviewResult;
182
+ if (cached && cached.requestJson === JSON.stringify(request)) {
183
+ if (cached.outcome.status === "rejected") throw cached.outcome.reason;
184
+ result = cached.outcome.value;
185
+ } else {
186
+ // A sibling's validated input may differ from its arguments in the assistant message.
187
+ result = await reviewToolRequest(ctx, request);
188
+ }
189
+ if (ctx.signal?.aborted) return block("Tool review cancelled");
190
+ const { review, provider, model } = result;
146
191
  if (review.decision === "requires_user_approval") {
147
192
  return requestToolApproval(
148
- pi,
149
193
  ctx,
194
+ event.toolCallId,
150
195
  request.toolName,
151
196
  `Approve high-impact ${toolLabel(request.toolName)}?`,
152
197
  formatApproval(review.summary, review.reason),
@@ -161,18 +206,19 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
161
206
  return undefined;
162
207
  }
163
208
  return requestToolApproval(
164
- pi,
165
209
  ctx,
210
+ event.toolCallId,
166
211
  request.toolName,
167
212
  `Run reviewed ${toolLabel(request.toolName)}?`,
168
213
  formatApproval(review.summary, "Automatic approval is disabled."),
169
214
  );
170
215
  } catch (error) {
216
+ if (ctx.signal?.aborted) return block("Tool review cancelled");
171
217
  const message = singleLine(errorText(error));
172
218
  ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
173
219
  return requestToolApproval(
174
- pi,
175
220
  ctx,
221
+ event.toolCallId,
176
222
  request.toolName,
177
223
  `Automatic ${toolLabel(request.toolName)} review failed. Continue?`,
178
224
  `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 +228,33 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
182
228
  }
183
229
  });
184
230
 
231
+ pi.on("turn_end", () => {
232
+ batchReviews = undefined;
233
+ });
234
+
235
+ pi.on("tool_result", (event) => {
236
+ const note = pendingNotes.get(event.toolCallId);
237
+ if (!note) return;
238
+ pendingNotes.delete(event.toolCallId);
239
+ return {
240
+ content: [
241
+ ...event.content,
242
+ {
243
+ type: "text" as const,
244
+ text: `User approval note (guidance for subsequent actions; does not change this tool request):\n${note}`,
245
+ },
246
+ ],
247
+ };
248
+ });
249
+
250
+ pi.on("agent_end", () => {
251
+ batchReviews = undefined;
252
+ pendingNotes.clear();
253
+ });
254
+
185
255
  pi.on("session_shutdown", (_event, ctx) => {
256
+ batchReviews = undefined;
257
+ pendingNotes.clear();
186
258
  ctx.ui.setStatus(STATUS_KEY, undefined);
187
259
  });
188
260
  }
@@ -208,10 +280,49 @@ function autoApprovedMarker(value: unknown): AutoApprovedMarker | undefined {
208
280
  return { toolName: record.toolName, provider: record.provider, model: record.model };
209
281
  }
210
282
 
211
- async function reviewToolRequest(
283
+ async function reviewAssistantRequests(
212
284
  ctx: ExtensionContext,
213
- request: ToolApprovalRequest,
214
- ): Promise<{ review: ToolReview; provider: string; model: string }> {
285
+ event: ToolCallEvent,
286
+ currentRequest: ToolApprovalRequest,
287
+ ): Promise<Map<string, CachedReview>> {
288
+ const reviews = new Map<string, CachedReview>();
289
+ const assistant = ctx.sessionManager
290
+ .getBranch()
291
+ .reverse()
292
+ .find((entry) => entry.type === "message" && entry.message.role === "assistant");
293
+ if (!assistant || assistant.type !== "message" || assistant.message.role !== "assistant") return reviews;
294
+ if (
295
+ !assistant.message.content.some(
296
+ (part) => part.type === "toolCall" && part.id === event.toolCallId && part.name === currentRequest.toolName,
297
+ )
298
+ )
299
+ return reviews;
300
+
301
+ const requests = assistant.message.content.flatMap((part) => {
302
+ if (part.type !== "toolCall" || (part.name !== "bash" && part.name !== "script_runner")) return [];
303
+ const request: ToolApprovalRequest = {
304
+ toolName: part.name,
305
+ input: part.id === event.toolCallId ? currentRequest.input : part.arguments,
306
+ };
307
+ if (request.toolName === "bash") {
308
+ const command = request.input.command;
309
+ if (typeof command !== "string" || !command.trim() || isAllowlistedBash(command)) return [];
310
+ }
311
+ return [{ toolCallId: part.id, request, requestJson: JSON.stringify(request) }];
312
+ });
313
+
314
+ for (let index = 0; index < requests.length && !ctx.signal?.aborted; index += MAX_CONCURRENT_REVIEWS) {
315
+ const group = requests.slice(index, index + MAX_CONCURRENT_REVIEWS);
316
+ const outcomes = await Promise.allSettled(group.map((item) => reviewToolRequest(ctx, item.request)));
317
+ for (const [offset, item] of group.entries()) {
318
+ const outcome = outcomes[offset];
319
+ if (outcome) reviews.set(item.toolCallId, { requestJson: item.requestJson, outcome });
320
+ }
321
+ }
322
+ return reviews;
323
+ }
324
+
325
+ async function reviewToolRequest(ctx: ExtensionContext, request: ToolApprovalRequest): Promise<ToolReviewResult> {
215
326
  const requestJson = JSON.stringify(request);
216
327
  const reviewer = REVIEW_MODELS.find((item) => item.provider === ctx.model?.provider);
217
328
  const preferred = reviewer ? [reviewer] : [];
@@ -269,29 +380,6 @@ function formatApproval(summary: string, reason: string): string {
269
380
  return singleLine(`${summary} ${reason}`);
270
381
  }
271
382
 
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
383
  function singleLine(text: string): string {
296
384
  return text.replaceAll(/\s+/g, " ").trim();
297
385
  }
@@ -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.
@@ -1,6 +1,9 @@
1
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { defineTool, type ExtensionAPI, type SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
3
  import { registeredDeferredToolGroups, type DeferredToolGroupInfo } from "../../src/tool-loading/index.ts";
4
+ import { emitTauEvent } from "../../shared/events.ts";
5
+ import { BoundedTextResultBuilder } from "../../shared/bounded-text-result.ts";
6
+ import { createTemporaryOutputStore } from "../../shared/temporary-output-store.ts";
4
7
 
5
8
  const loadToolsSchema = Type.Object(
6
9
  {
@@ -21,14 +24,25 @@ interface LoadToolsDetails {
21
24
  addedToolNames: string[];
22
25
  }
23
26
 
27
+ const PENDING_TYPE = "tau.tool-loader.pending";
28
+ const APPLIED_TYPE = "tau.tool-loader.applied";
29
+
30
+ interface AppliedLoad {
31
+ pendingId: string;
32
+ names: string[];
33
+ }
34
+
24
35
  export default function toolLoaderExtension(pi: ExtensionAPI): void {
36
+ let temporaryOutput = createTemporaryOutputStore();
25
37
  let managed = false;
26
38
  let allowedToolNames = new Map<string, ReadonlySet<string>>();
27
39
  let managedToolNames = new Set<string>();
28
40
 
29
41
  pi.registerTool(createLoadToolsTool(pi, []));
30
42
 
31
- pi.on("session_start", (_event, ctx) => {
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ temporaryOutput = createTemporaryOutputStore();
45
+ await temporaryOutput.start();
32
46
  const groups = registeredDeferredToolGroups(pi);
33
47
  pi.registerTool(createLoadToolsTool(pi, groups));
34
48
 
@@ -39,20 +53,43 @@ export default function toolLoaderExtension(pi: ExtensionAPI): void {
39
53
  );
40
54
  managedToolNames = new Set(groups.flatMap((group) => group.toolNames));
41
55
  managed = initialSet.has("load_tools") && groups.length > 0;
42
- if (managed) restoreActiveTools(pi, initial, loadedCapabilities(ctx.sessionManager.getBranch()), groups);
56
+ if (managed) restoreActiveTools(pi, initial, loadedCapabilities(ctx.sessionManager.getBranch(), groups), groups);
43
57
  });
58
+ pi.on("session_shutdown", () => temporaryOutput.shutdown());
44
59
 
45
60
  pi.on("session_tree", (_event, ctx) => {
46
61
  if (managed) {
47
62
  restoreActiveTools(
48
63
  pi,
49
64
  pi.getActiveTools(),
50
- loadedCapabilities(ctx.sessionManager.getBranch()),
65
+ loadedCapabilities(ctx.sessionManager.getBranch(), registeredDeferredToolGroups(pi)),
51
66
  registeredDeferredToolGroups(pi),
52
67
  );
53
68
  }
54
69
  });
55
70
 
71
+ pi.on("session_compact", (_event, ctx) => {
72
+ const branch = ctx.sessionManager.getBranch();
73
+ const applied = new Set(
74
+ branch.flatMap((entry) =>
75
+ entry.type === "custom" && entry.customType === APPLIED_TYPE ? [(entry.data as AppliedLoad).pendingId] : [],
76
+ ),
77
+ );
78
+ for (const entry of branch) {
79
+ if (
80
+ entry.type !== "custom" ||
81
+ entry.customType !== PENDING_TYPE ||
82
+ applied.has(entry.id) ||
83
+ !Array.isArray(entry.data)
84
+ )
85
+ continue;
86
+ const names = entry.data.filter((name): name is string => typeof name === "string");
87
+ const allowed = new Set([...allowedToolNames.values()].flatMap((group) => [...group]));
88
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...names.filter((name) => allowed.has(name))])]);
89
+ pi.appendEntry(APPLIED_TYPE, { pendingId: entry.id, names });
90
+ }
91
+ });
92
+
56
93
  function createLoadToolsTool(pi: ExtensionAPI, groups: readonly DeferredToolGroupInfo[]) {
57
94
  return defineTool<typeof loadToolsSchema, LoadToolsDetails>({
58
95
  name: "load_tools",
@@ -63,7 +100,7 @@ export default function toolLoaderExtension(pi: ExtensionAPI): void {
63
100
  "Use load_tools before attempting a registered specialist capability whose tools are not currently available.",
64
101
  ],
65
102
  parameters: loadToolsSchema,
66
- async execute(_toolCallId, params: LoadToolsParams) {
103
+ async execute(_toolCallId, params: LoadToolsParams, signal, _onUpdate, ctx) {
67
104
  const group = registeredDeferredToolGroups(pi).find((candidate) => candidate.id === params.capability);
68
105
  if (group === undefined) {
69
106
  throw new Error(
@@ -81,7 +118,25 @@ export default function toolLoaderExtension(pi: ExtensionAPI): void {
81
118
  }
82
119
 
83
120
  const beforeSet = new Set(before);
84
- pi.setActiveTools([...before, ...loadable.filter((name) => !beforeSet.has(name))]);
121
+ const next = [...before, ...loadable.filter((name) => !beforeSet.has(name))];
122
+ const nextSet = new Set(next);
123
+ let blocked: string | null = null;
124
+ emitTauEvent(pi, "tau:prompt.tools.check", {
125
+ ctx,
126
+ tools: pi.getAllTools().filter((tool) => nextSet.has(tool.name)),
127
+ reject(reason) {
128
+ blocked = reason;
129
+ },
130
+ });
131
+ if (blocked) {
132
+ pi.appendEntry(PENDING_TYPE, loadable);
133
+ return boundedResult(
134
+ `${blocked} ${params.capability} is queued for activation after successful compaction.`,
135
+ { version: 1, capability: params.capability, requestedToolNames: requested, addedToolNames: [] },
136
+ signal,
137
+ );
138
+ }
139
+ pi.setActiveTools(next);
85
140
  const after = pi.getActiveTools();
86
141
  const addedToolNames = requested.filter((name) => !beforeSet.has(name) && after.includes(name));
87
142
  const available = requested.filter((name) => after.includes(name));
@@ -91,24 +146,36 @@ export default function toolLoaderExtension(pi: ExtensionAPI): void {
91
146
  ? `Loaded ${params.capability} tools: ${addedToolNames.join(", ")}.`
92
147
  : `${params.capability} tools are already loaded: ${available.join(", ")}.`;
93
148
 
94
- return {
95
- content: [
96
- {
97
- type: "text" as const,
98
- text: unavailable.length ? `${text} Unavailable: ${unavailable.join(", ")}.` : text,
99
- },
100
- ],
101
- details: {
149
+ return boundedResult(
150
+ unavailable.length ? `${text} Unavailable: ${unavailable.join(", ")}.` : text,
151
+ {
102
152
  version: 1,
103
153
  capability: params.capability,
104
154
  requestedToolNames: requested,
105
155
  addedToolNames,
106
156
  },
107
- };
157
+ signal,
158
+ );
108
159
  },
109
160
  });
110
161
  }
111
162
 
163
+ async function boundedResult(text: string, details: LoadToolsDetails, signal: AbortSignal | undefined) {
164
+ const builder = new BoundedTextResultBuilder(temporaryOutput, "head");
165
+ try {
166
+ await builder.append(text);
167
+ signal?.throwIfAborted();
168
+ const result = await builder.finish();
169
+ return {
170
+ content: [{ type: "text" as const, text: result.content }],
171
+ details: { ...details, overflow: result.overflow },
172
+ };
173
+ } catch (error) {
174
+ await builder.abort();
175
+ throw error;
176
+ }
177
+ }
178
+
112
179
  function restoreActiveTools(
113
180
  pi: ExtensionAPI,
114
181
  current: readonly string[],
@@ -131,16 +198,18 @@ function formatGroupCatalog(groups: readonly DeferredToolGroupInfo[]): string {
131
198
  return ` Registered groups: ${catalog}.`;
132
199
  }
133
200
 
134
- function loadedCapabilities(entries: readonly unknown[]): Set<string> {
201
+ function loadedCapabilities(entries: readonly SessionEntry[], groups: readonly DeferredToolGroupInfo[]): Set<string> {
135
202
  const loaded = new Set<string>();
136
- for (const value of entries) {
137
- if (!value || typeof value !== "object") continue;
138
- const entry = value as Record<string, unknown>;
139
- if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") continue;
140
- const message = entry.message as Record<string, unknown>;
203
+ for (const entry of entries) {
204
+ if (entry.type === "custom" && entry.customType === APPLIED_TYPE) {
205
+ const { names } = entry.data as AppliedLoad;
206
+ for (const group of groups) if (group.toolNames.some((name) => names.includes(name))) loaded.add(group.id);
207
+ }
208
+ if (entry.type !== "message") continue;
209
+ const message = entry.message;
141
210
  if (message.role !== "toolResult" || message.toolName !== "load_tools" || message.isError === true) continue;
142
211
  if (!isLoadToolsDetails(message.details)) continue;
143
- loaded.add(message.details.capability);
212
+ if (message.details.addedToolNames.length > 0) loaded.add(message.details.capability);
144
213
  }
145
214
  return loaded;
146
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.42.3",
3
+ "version": "0.44.0",
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.42.3",
38
+ "@shanepadgett/tau-tui": "0.44.0",
39
39
  "@vscode/tree-sitter-wasm": "0.3.1",
40
40
  "image-size": "2.0.4",
41
41
  "smol-toml": "1.8.0",