tau-all-agent 0.2.0 → 0.3.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 (42) hide show
  1. package/extensions/answer.ts +112 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +67 -36
  4. package/extensions/insights.ts +67 -56
  5. package/extensions/loop.ts +14 -52
  6. package/extensions/memory.ts +65 -33
  7. package/extensions/notify.ts +5 -3
  8. package/extensions/review/fix.ts +10 -16
  9. package/extensions/review/git.ts +7 -1
  10. package/extensions/review/index.ts +2 -2
  11. package/extensions/review/models.ts +12 -2
  12. package/extensions/review/review.ts +3 -5
  13. package/extensions/review/runtime.ts +9 -8
  14. package/extensions/review/triage.ts +2 -4
  15. package/extensions/sandbox/README.md +5 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/index.ts +21 -11
  18. package/extensions/sandbox/permissions/network.ts +8 -3
  19. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  20. package/extensions/sandbox/runtime.ts +53 -19
  21. package/extensions/subagent/README.md +58 -1
  22. package/extensions/subagent/config.ts +118 -0
  23. package/extensions/subagent/index.ts +54 -22
  24. package/extensions/telegram/README.md +2 -0
  25. package/extensions/telegram/assistant-result.d.mts +4 -1
  26. package/extensions/telegram/assistant-result.mjs +31 -16
  27. package/extensions/telegram/daemon.mjs +146 -194
  28. package/extensions/telegram/index.ts +11 -7
  29. package/extensions/tool-display-mode.ts +29 -50
  30. package/extensions/usage/README.md +5 -0
  31. package/extensions/usage/index.ts +139 -80
  32. package/extensions/websearch/README.md +8 -1
  33. package/extensions/websearch/config.ts +2 -1
  34. package/extensions/websearch/index.ts +18 -9
  35. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  36. package/extensions/websearch/providers/shared.ts +10 -3
  37. package/extensions/websearch/types.ts +2 -1
  38. package/extensions/worktree.ts +33 -89
  39. package/package.json +1 -1
  40. package/skills/oracle/SKILL.md +14 -8
  41. package/skills/oracle/scripts/oracle +43 -19
  42. package/skills/update-changelog/SKILL.md +8 -4
@@ -1,22 +1,3 @@
1
- import { homedir } from "node:os";
2
- import {
3
- DynamicBorder,
4
- type ExtensionContext,
5
- type KeybindingsManager,
6
- type Theme,
7
- } from "@earendil-works/pi-coding-agent";
8
- import {
9
- CURSOR_MARKER,
10
- Text,
11
- TUI_KEYBINDINGS,
12
- visibleWidth,
13
- type Component,
14
- type Focusable,
15
- type TUI,
16
- type TuiMouseEvent,
17
- type TuiMouseEventResult,
18
- } from "@earendil-works/pi-tui";
19
-
20
1
  export const UNSANDBOXED_APPROVAL_CHOICES = ["Deny", "Run once outside sandbox"] as const;
21
2
 
22
3
  export function isUnsandboxedApproval(choices: readonly string[]): boolean {
@@ -26,237 +7,17 @@ export function isUnsandboxedApproval(choices: readonly string[]): boolean {
26
7
  );
27
8
  }
28
9
 
29
- /** Use Pi's editor-area dialog. RPC presentation belongs to its human client. */
30
- export async function showUnsandboxedApproval(
31
- ctx: ExtensionContext,
32
- title: string,
33
- signal?: AbortSignal,
34
- ): Promise<string | undefined> {
35
- if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return undefined;
36
- let cancel: (() => void) | undefined;
37
- try {
38
- const result = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
39
- cancel = () => done(undefined);
40
- signal?.addEventListener("abort", cancel, { once: true });
41
- if (signal?.aborted) cancel();
42
- return new UnsandboxedApproval(title, tui, theme, keybindings, (choice) =>
43
- done(signal?.aborted ? undefined : choice),
44
- );
45
- });
46
- return signal?.aborted ? undefined : result;
47
- } catch {
48
- return undefined;
49
- } finally {
50
- if (cancel) signal?.removeEventListener("abort", cancel);
51
- }
52
- }
53
-
54
10
  /** Shell-style details for both the local dialog and forwarded RPC approvals. */
55
- export function formatUnsandboxedApproval(command: string, cwd: string): string {
56
- const home = homedir();
57
- const homePrefix = home.endsWith("/") ? home : `${home}/`;
58
- const directory =
59
- cwd === home ? "~" : cwd.startsWith(homePrefix) ? `~/${cwd.slice(homePrefix.length)}` : cwd;
11
+ export function formatUnsandboxedApproval(command: string): string {
60
12
  return [
61
13
  "Run once outside sandbox?",
62
14
  "",
63
- escapeControls(directory).replaceAll("\n", "\\u000a"),
64
15
  `$ ${escapeControls(command)}`,
65
16
  "",
66
17
  "This command and its descendants get host filesystem and network access.",
67
18
  ].join("\n");
68
19
  }
69
20
 
70
- /** The cursor keeps the focused review line or choice visible when Pi shrinks its editor dock. */
71
- class UnsandboxedApproval implements Component, Focusable {
72
- focused = false;
73
- private readonly content = new Text("", 0, 0);
74
- private readonly titleLines: string[];
75
- private readonly directoryLine: number;
76
- private selected = 0;
77
- private reviewLine: number | undefined;
78
- private scrollTop = 0;
79
- private contentLines: string[] = [];
80
- private viewportHeight = 0;
81
- private renderedWidth = 0;
82
- private renderedHeight = 0;
83
- private canApprove = false;
84
-
85
- constructor(
86
- title: string,
87
- private readonly tui: TUI,
88
- private readonly theme: Theme,
89
- private readonly keybindings: KeybindingsManager,
90
- private readonly done: (choice: string | undefined) => void,
91
- ) {
92
- this.titleLines = escapeControls(title).split("\n");
93
- this.directoryLine = this.titleLines.findIndex((line) => line.startsWith("$ ")) - 1;
94
- }
95
-
96
- render(width: number): string[] {
97
- const rows = this.tui.terminal.rows;
98
- const height = Math.min(16, Math.max(0, rows - 2));
99
- if (width !== this.renderedWidth || rows !== this.renderedHeight) this.selected = 0;
100
- this.renderedWidth = width;
101
- this.renderedHeight = rows;
102
- this.canApprove = false;
103
- // Fullscreen owns unmodified PageUp/PageDown and Home/End before dialog input.
104
- // Advertise the configured editor aliases that can actually reach this dialog.
105
- const viewportKeys = new Set(
106
- this.tui.mode === "fullscreen"
107
- ? Object.keys(TUI_KEYBINDINGS)
108
- .filter((action) => action.startsWith("tui.altScreen."))
109
- .flatMap((action) => this.keybindings.getKeys(action as keyof typeof TUI_KEYBINDINGS))
110
- : [],
111
- );
112
- const key = (action: Parameters<KeybindingsManager["getKeys"]>[0]) =>
113
- this.keybindings
114
- .getKeys(action)
115
- .find((binding) => !action.startsWith("tui.editor.") || !viewportKeys.has(binding)) ??
116
- "unbound";
117
- const border = new DynamicBorder((line) => this.theme.fg("border", line)).render(width);
118
- const gap = height >= 14 ? [""] : [];
119
- this.content.setText(
120
- this.titleLines
121
- .map((line, index) =>
122
- index === this.directoryLine
123
- ? this.theme.fg("dim", line)
124
- : line.startsWith("$ ")
125
- ? this.theme.fg("text", line)
126
- : line,
127
- )
128
- .join("\n"),
129
- );
130
- this.contentLines = this.content.render(Math.max(1, width - 2));
131
- const choices = UNSANDBOXED_APPROVAL_CHOICES.flatMap((choice, index) =>
132
- new Text(
133
- this.theme.fg(
134
- index === this.selected ? "accent" : "text",
135
- `${this.focused && this.reviewLine === undefined && index === this.selected ? CURSOR_MARKER : ""}${index === this.selected ? "→" : " "} ${choice}`,
136
- ),
137
- 1,
138
- 0,
139
- ).render(width),
140
- );
141
- const navigation =
142
- key("tui.select.up") === "up" && key("tui.select.down") === "down"
143
- ? "↑↓"
144
- : `${key("tui.select.up")}/${key("tui.select.down")}`;
145
- const hint = (binding: string, description: string) =>
146
- this.theme.fg("dim", binding) + this.theme.fg("muted", ` ${description}`);
147
- const hints = new Text(
148
- `${hint(navigation, "navigate")} ${hint(key("tui.select.confirm"), "select")} ${hint(key("tui.select.cancel"), "cancel")}`,
149
- 1,
150
- 0,
151
- ).render(width);
152
- this.viewportHeight =
153
- height - border.length * 2 - gap.length * 4 - choices.length - hints.length;
154
- const reviewHints =
155
- this.contentLines.length > this.viewportHeight || this.reviewLine !== undefined
156
- ? new Text(
157
- this.theme.fg(
158
- "dim",
159
- `${key("tui.editor.pageUp")}/${key("tui.editor.pageDown")} review lines · ${key("tui.editor.cursorLineStart")}/${key("tui.editor.cursorLineEnd")} top/end`,
160
- ),
161
- 1,
162
- 0,
163
- ).render(width)
164
- : [];
165
- this.viewportHeight -= reviewHints.length;
166
- if (width < 3 || this.viewportHeight < 1) {
167
- return new Text("Resize to review. Escape denies.", 0, 0)
168
- .render(Math.max(1, width))
169
- .slice(0, Math.max(0, height));
170
- }
171
- this.viewportHeight = Math.min(this.viewportHeight, this.contentLines.length);
172
- if (this.reviewLine !== undefined) {
173
- this.reviewLine = Math.min(this.reviewLine, this.contentLines.length - 1);
174
- this.scrollTop = Math.max(0, this.reviewLine - this.viewportHeight + 1);
175
- }
176
- this.scrollTop = Math.min(
177
- this.scrollTop,
178
- Math.max(0, this.contentLines.length - this.viewportHeight),
179
- );
180
- this.canApprove =
181
- this.focused &&
182
- this.reviewLine === undefined &&
183
- this.selected === 1 &&
184
- width >= visibleWidth(` → ${UNSANDBOXED_APPROVAL_CHOICES[1]} `);
185
- const visible = Array.from({ length: this.viewportHeight }, (_, index) => {
186
- const row = this.scrollTop + index;
187
- const line = this.contentLines[row] ?? "";
188
- const cursor = this.focused && row === this.reviewLine ? CURSOR_MARKER : "";
189
- const text = row === 0 ? this.theme.fg("accent", this.theme.bold(line)) : line;
190
- return ` ${cursor}${text}`;
191
- });
192
- return [
193
- ...border,
194
- ...gap,
195
- ...visible,
196
- ...gap,
197
- ...choices,
198
- ...gap,
199
- ...hints,
200
- ...reviewHints,
201
- ...gap,
202
- ...border,
203
- ];
204
- }
205
-
206
- handleInput(data: string): void {
207
- const kb = this.keybindings;
208
- if (kb.matches(data, "tui.select.cancel")) {
209
- this.done(undefined);
210
- return;
211
- }
212
- if (
213
- this.renderedWidth !== this.tui.terminal.columns ||
214
- this.renderedHeight !== this.tui.terminal.rows
215
- ) {
216
- this.canApprove = false;
217
- this.selected = 0;
218
- this.tui.requestRender();
219
- return;
220
- }
221
- if (kb.matches(data, "tui.select.confirm")) {
222
- if (this.selected === 0) this.done("Deny");
223
- else if (this.canApprove) this.done("Run once outside sandbox");
224
- return;
225
- }
226
- if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
227
- this.selected = kb.matches(data, "tui.select.down") && this.viewportHeight > 0 ? 1 : 0;
228
- this.reviewLine = undefined;
229
- this.canApprove = false;
230
- }
231
- // Pi does not expose the editor dock's height. Advance one review line so its
232
- // cursor can reveal every line even when only part of this component fits.
233
- else if (kb.matches(data, "tui.editor.pageUp")) this.scroll(-1);
234
- else if (kb.matches(data, "tui.editor.pageDown")) this.scroll(1);
235
- else if (kb.matches(data, "tui.editor.cursorLineStart")) this.scroll(-this.contentLines.length);
236
- else if (kb.matches(data, "tui.editor.cursorLineEnd")) this.scroll(this.contentLines.length);
237
- this.tui.requestRender();
238
- }
239
-
240
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
241
- if (event.type !== "wheel") return undefined;
242
- this.scroll(event.wheelDelta ?? 0);
243
- return { handled: true, render: true };
244
- }
245
-
246
- invalidate(): void {
247
- this.content.invalidate();
248
- }
249
-
250
- private scroll(lines: number): void {
251
- this.selected = 0;
252
- this.canApprove = false;
253
- this.reviewLine = Math.max(
254
- 0,
255
- Math.min(this.contentLines.length - 1, (this.reviewLine ?? 0) + lines),
256
- );
257
- }
258
- }
259
-
260
21
  /** Preserve layout newlines while making untrusted terminal and Unicode controls inert. */
261
22
  function escapeControls(text: string): string {
262
23
  return text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (character) =>
@@ -1,4 +1,8 @@
1
- import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
1
+ import {
2
+ SandboxManager,
3
+ type SandboxAskCallback,
4
+ type SandboxRuntimeConfig,
5
+ } from "@anthropic-ai/sandbox-runtime";
2
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
7
 
4
8
  import {
@@ -16,7 +20,6 @@ import {
16
20
  type SandboxConfigPath,
17
21
  } from "./config.js";
18
22
  import { createNetworkPermissions } from "./permissions/network.js";
19
- import { isUnsandboxedApproval, showUnsandboxedApproval } from "./permissions/unsandboxed.js";
20
23
 
21
24
  const STATUS_KEY = "sandbox";
22
25
  const SANDBOX_EVENT_LIMIT = 50;
@@ -70,11 +73,11 @@ export interface SandboxRuntime {
70
73
  readonly configPaths: SandboxConfigPath[];
71
74
  readonly events: SandboxEvent[];
72
75
  getRuntimeConfig(): SandboxRuntimeConfig | null;
73
- captureContext(ctx: ExtensionContext): void;
74
- withPermissionContext<T>(
75
- ctx: ExtensionContext,
76
+ withBashContext<T>(ctx: ExtensionContext, signal: AbortSignal, run: () => Promise<T>): Promise<T>;
77
+ withPermissionContext<C extends ExtensionContext, T>(
78
+ ctx: C,
76
79
  signal: AbortSignal | undefined,
77
- run: (ctx: ExtensionContext, signal: AbortSignal) => Promise<T>,
80
+ run: (ctx: C, signal: AbortSignal) => Promise<T>,
78
81
  ): Promise<T>;
79
82
  start(ctx: ExtensionContext): Promise<void>;
80
83
  shutdown(ctx: ExtensionContext): Promise<void>;
@@ -230,6 +233,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
230
233
  const warnedSkippedProjectConfigPaths = new Set<string>();
231
234
  let permissionLifetime = new AbortController();
232
235
  const permissionInvocations = new Set<Promise<unknown>>();
236
+ let networkInvocation: { signal: AbortSignal; pending: Set<Promise<boolean>> } | undefined;
233
237
 
234
238
  function recordSandboxEvent(event: SandboxEvent): void {
235
239
  sandboxEvents.push(event);
@@ -298,6 +302,19 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
298
302
  notify,
299
303
  });
300
304
 
305
+ const askNetworkPermission: SandboxAskCallback = async (request) => {
306
+ const owner = networkInvocation;
307
+ if (!owner || owner.signal.aborted) return false;
308
+
309
+ const decision = networkPermissions.ask(request, owner.signal);
310
+ owner.pending.add(decision);
311
+ try {
312
+ return await decision;
313
+ } finally {
314
+ owner.pending.delete(decision);
315
+ }
316
+ };
317
+
301
318
  async function initializeSandboxRuntime(
302
319
  ctx: ExtensionContext,
303
320
  config: SandboxConfig,
@@ -326,7 +343,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
326
343
  const runtimeConfig = toRuntimeConfig(config);
327
344
 
328
345
  try {
329
- await SandboxManager.initialize(runtimeConfig, networkPermissions.ask, true);
346
+ await SandboxManager.initialize(runtimeConfig, askNetworkPermission, true);
330
347
  const activeConfig = cloneRuntimeConfig(runtimeConfig);
331
348
  sandboxState = { status: "active", runtimeConfig: activeConfig };
332
349
  return activeConfig;
@@ -491,11 +508,33 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
491
508
  }
492
509
  }
493
510
 
494
- /** Own invocation-local permission work until its dialog or command has settled. */
495
- function withPermissionContext<T>(
511
+ /** Hold the Bash lane until asynchronous proxy approvals have been revoked and joined. */
512
+ async function withBashContext<T>(
496
513
  ctx: ExtensionContext,
514
+ signal: AbortSignal,
515
+ run: () => Promise<T>,
516
+ ): Promise<T> {
517
+ const completion = new AbortController();
518
+ const permissionSignal = AbortSignal.any([signal, completion.signal]);
519
+ const owner = { signal: permissionSignal, pending: new Set<Promise<boolean>>() };
520
+ const previousContext = sessionContext;
521
+ sessionContext = permissionContext(ctx, permissionSignal);
522
+ networkInvocation = owner;
523
+ try {
524
+ return await run();
525
+ } finally {
526
+ completion.abort();
527
+ while (owner.pending.size) await Promise.allSettled(owner.pending);
528
+ networkInvocation = undefined;
529
+ sessionContext = previousContext;
530
+ }
531
+ }
532
+
533
+ /** Own invocation-local permission work until its dialog or command has settled. */
534
+ function withPermissionContext<C extends ExtensionContext, T>(
535
+ ctx: C,
497
536
  signal: AbortSignal | undefined,
498
- run: (ctx: ExtensionContext, signal: AbortSignal) => Promise<T>,
537
+ run: (ctx: C, signal: AbortSignal) => Promise<T>,
499
538
  ): Promise<T> {
500
539
  const signals = [permissionLifetime.signal, ctx.signal, signal].filter(
501
540
  (value): value is AbortSignal => value !== undefined,
@@ -515,7 +554,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
515
554
  await Promise.allSettled(permissionInvocations);
516
555
  }
517
556
 
518
- function permissionContext(ctx: ExtensionContext, contextSignal = ctx.signal): ExtensionContext {
557
+ function permissionContext<C extends ExtensionContext>(ctx: C, contextSignal = ctx.signal): C {
519
558
  function queued<T>(
520
559
  run: (signal?: AbortSignal) => Promise<T>,
521
560
  cancelled: T,
@@ -545,10 +584,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
545
584
  const overrides: Pick<ExtensionContext["ui"], "select" | "confirm"> = {
546
585
  select: (title, options, opts) =>
547
586
  queued(
548
- (signal) =>
549
- isUnsandboxedApproval(options) && ctx.mode === "tui"
550
- ? showUnsandboxedApproval(ctx, title, signal)
551
- : ctx.ui.select(title, options, { ...opts, signal }),
587
+ (signal) => ctx.ui.select(title, options, { ...opts, signal }),
552
588
  undefined,
553
589
  opts?.signal,
554
590
  ),
@@ -578,7 +614,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
578
614
  },
579
615
  },
580
616
  },
581
- ) as ExtensionContext;
617
+ ) as C;
582
618
  }
583
619
 
584
620
  return {
@@ -601,9 +637,7 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
601
637
  return sandboxEvents;
602
638
  },
603
639
  getRuntimeConfig: () => getStateRuntimeConfig(sandboxState),
604
- captureContext(ctx) {
605
- sessionContext = permissionContext(ctx);
606
- },
640
+ withBashContext,
607
641
  withPermissionContext,
608
642
  start,
609
643
  shutdown,
@@ -23,7 +23,7 @@ The model and thinking level default to the parent's settings when the child sta
23
23
 
24
24
  Match model capability to the task. Favor faster, less capable models for mechanical work and well-defined, bounded tasks. Favor more capable models for complex, ambiguous, or high-stakes work. Override the parent's model in either direction when there is a clear benefit. Choose model and thinking level independently. If no suitable alternative is known to be available, omit `model` to inherit.
25
25
 
26
- The thinking-level guidance favors inheritance. Override the thinking level only when the task clearly warrants more or less reasoning. When choosing an override:
26
+ The built-in model and thinking-level guidance favors inheritance. Choose a different model or thinking level only when the task clearly benefits from different model capabilities or reasoning effort. The built-in [customizable recommendations](#selection-recommendations) suggest these thinking levels:
27
27
 
28
28
  - **Low:** mechanical searches and extraction.
29
29
  - **Medium:** bounded edits or tests with a well-defined approach.
@@ -34,6 +34,59 @@ These are recommendations, not fixed tiers. There is no automatic upgrade or dow
34
34
 
35
35
  `steer` is cooperative: a running child receives the message after its current tool batch, before its next model call. It does not interrupt an in-flight shell command. An idle child starts another turn with its existing history.
36
36
 
37
+ ## Selection recommendations
38
+
39
+ Override or extend the task guidance in `<agent-dir>/subagent.json`, normally `~/.pi/agent/subagent.json`. This is an optional, agent-global file. There are no project overrides or configuration commands. Run `/reload` after editing it.
40
+
41
+ The file is an array of overrides and additions, **not a replacement for the built-in recommendations**. All four built-ins (`low`, `medium`, `high`, `xhigh`) are enabled by default. A missing file or `[]` leaves the defaults unchanged.
42
+
43
+ For example, using illustrative model IDs:
44
+
45
+ ```json
46
+ [
47
+ {
48
+ "id": "low",
49
+ "model": "provider/fast-model"
50
+ },
51
+ {
52
+ "id": "medium",
53
+ "model": "provider/coding-model",
54
+ "thinking": "inherit"
55
+ },
56
+ {
57
+ "id": "high",
58
+ "model": "inherit",
59
+ "thinking": "high"
60
+ },
61
+ {
62
+ "id": "xhigh",
63
+ "model": "provider/strong-model"
64
+ },
65
+ {
66
+ "id": "security-review",
67
+ "when": "Reviewing authentication, authorization, or trust boundaries.",
68
+ "model": "provider/strong-model",
69
+ "thinking": "high"
70
+ }
71
+ ]
72
+ ```
73
+
74
+ | Field | Meaning |
75
+ | ---------- | -------------------------------------------------------------------------------------------------------------------- |
76
+ | `id` | Required stable identifier. An existing ID merges supplied fields into that default. A new ID adds a recommendation. |
77
+ | `when` | Task guidance for the parent. Omit it to keep the built-in description. Required for a new recommendation. |
78
+ | `model` | Exact `provider/model` ID, or `"inherit"` to recommend the parent's model. |
79
+ | `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, or `"inherit"`. |
80
+ | `enabled` | Optional boolean. Set `false` to exclude a recommendation from the guidance. Defaults to enabled. |
81
+
82
+ A new recommendation requires `when` and at least one of `model` or `thinking`, even if disabled. Built-in IDs are labels, not inferred thinking levels: `"id": "medium"` can recommend `"thinking": "inherit"` or any supported level. Strings are trimmed, duplicate IDs and unknown fields are errors, and invalid configuration reports its path and prevents the parent extension from loading rather than silently using defaults.
83
+
84
+ Only supplied fields override defaults. In the example, `low` keeps its built-in task description and `low` thinking recommendation while gaining a preferred model. `high` explicitly recommends inheriting the parent's model. Unchanged fields continue receiving improvements to the built-ins. Existing recommendations retain their order; new ones are appended in file order. Order is not routing priority.
85
+
86
+ These settings change **advice**, not execution defaults or allowed choices. The effective recommendations appear in the tool description and parent instructions. The parent still decides which advice applies and chooses model and thinking independently. Omitting a field in the file keeps its built-in recommendation; `"inherit"` explicitly recommends omitting that tool argument. Do not pass the literal string `inherit` to the tool.
87
+
88
+ Model IDs are checked for `provider/model` syntax, not availability at configuration load. A model must be known and usable when actually selected, and an explicit thinking level must be supported by it. Explicit selection failures remain errors, with no silent substitution. Disabling a recommendation does not prohibit its model or thinking level.
89
+
37
90
  ## Display and cancellation
38
91
 
39
92
  A compact running count appears only while children are active. With Tool Display Mode and an embedding-capable editor, it joins the composer's working indicator, including when only children are running. Otherwise it appears above the composer. Pi's tool-expansion shortcut, normally **Ctrl+O**, reveals rows above the composer with each child's status, goal, model, and thinking level. There is no separate shortcut or transcript viewer.
@@ -46,6 +99,8 @@ Ghostty's title spinner stays active while children are working, even when the p
46
99
 
47
100
  While children or Review tasks are active, Escape asks **“Cancel all ongoing work?”**. **Enter** confirms cancellation of the parent and all active background work. Escape again or **No** dismisses the confirmation without stopping anything. Existing dialogs keep their own Escape handling, and idle children do not require confirmation. `stop` still cancels one child directly without affecting others.
48
101
 
102
+ Whole-run parent cancellation also stops active children during tool execution or retry backoff. An independently aborted child run is reported as `aborted`, retaining its available partial answer, and can still be continued with `steer`. This differs from `stop`, which closes the child process. Pi's retry-only cancellation (`abortRetry()`, including native retry Escape) does not currently report whole-run cancellation.
103
+
49
104
  Idle conversations remain available until stopped or the parent session closes. Reload, session replacement, and exit stop children and join their processes, pipes, pending startup, and approval requests. Navigating to another conversation branch also stops children so their answers cannot arrive on the wrong branch. Shutdown clears queued directions and requests a native abort before sending SIGTERM. Startup or unresponsive requests cannot block this indefinitely; SIGKILL is the final fallback.
50
105
 
51
106
  Children are marked with `TAU_SUBAGENT_CHILD=1`. They do not get the delegation tool or Telegram integration themselves. The parent also sets `TAU_SUBAGENT_UNSANDBOXED_APPROVAL=1` to advertise support for forwarding sandbox approval requests. These are internal launch markers, not user configuration or permission grants.
@@ -56,6 +111,8 @@ Children load normal Pi configuration, authentication, and extensions from the p
56
111
 
57
112
  When Tau's Sandbox is loaded, it explicitly passes its **current session policy** to the child, including temporary changes. A blocked or uninitialized parent sandbox prevents starting a child. A child still needs its own working sandbox prerequisites. Permissions subsequently granted to one child do not automatically grant access to its siblings or parent.
58
113
 
114
+ This policy applies to the child's Bash tool, including nested calls through codemode. It does not sandbox the Pi process, other tools, or MCP servers. MCP subprocesses run with the child's host permissions.
115
+
59
116
  Standard child selection, confirmation, and text-input requests appear in the parent UI, labelled with the child's ID and goal. A single queue handles these requests and the parent's Sandbox approvals. Cancelling a queued child removes its request without dismissing another child's dialog. No agent message counts as permission; only the user's actual response is returned to the child. Non-interactive sandbox policy remains non-interactive, and requests without a parent UI are denied.
60
117
 
61
118
  Pi does not queue arbitrary extension dialogs. This integration waits for an already-open Pi prompt, but unrelated extensions can still open their own dialogs without joining the approval queue. RPC also cannot display custom TUI components; multiline editor requests are cancelled because Pi does not provide cancellable forwarding for them.
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ interface Recommendation {
6
+ id: string;
7
+ when: string;
8
+ model?: string;
9
+ thinking?: string;
10
+ enabled?: boolean;
11
+ }
12
+
13
+ const DEFAULT_RECOMMENDATIONS: readonly Recommendation[] = [
14
+ {
15
+ id: "low",
16
+ when: "mechanical searches and extraction",
17
+ thinking: "low",
18
+ },
19
+ {
20
+ id: "medium",
21
+ when: "bounded edits or tests with a well-defined approach",
22
+ thinking: "medium",
23
+ },
24
+ {
25
+ id: "high",
26
+ when: "non-trivial implementation tasks, cross-cutting changes, and security or concurrency review with a reasonably understood problem and direction",
27
+ thinking: "high",
28
+ },
29
+ {
30
+ id: "xhigh",
31
+ when: "difficult, open-ended reasoning that requires resolving substantial uncertainty or evaluating competing explanations and approaches. Ambiguous debugging and difficult investigations are examples",
32
+ thinking: "xhigh",
33
+ },
34
+ ];
35
+ const FIELDS = new Set(["id", "when", "model", "thinking", "enabled"]);
36
+ const THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max", "inherit"]);
37
+
38
+ export function loadRecommendations(): string[] {
39
+ const configPath = path.join(getAgentDir(), "subagent.json");
40
+ try {
41
+ let overrides: unknown = [];
42
+ try {
43
+ overrides = JSON.parse(readFileSync(configPath, "utf8"));
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
46
+ }
47
+ const recommendations = mergeRecommendations(overrides);
48
+ const enabled = recommendations.filter((recommendation) => recommendation.enabled !== false);
49
+ if (!enabled.length) return [];
50
+ return [
51
+ "Use these task-specific subagent recommendations when applicable, in preference to general selection guidance. They are advice, not fixed tiers or automatic routing. Choose model and thinking independently; a recommendation may suggest either or both. Inherit by omitting the corresponding tool argument, not by passing the literal string inherit.",
52
+ ...enabled.map(describeRecommendation),
53
+ ];
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ throw new Error(`Invalid subagent config at ${configPath}: ${message}`);
57
+ }
58
+ }
59
+
60
+ function mergeRecommendations(value: unknown): Recommendation[] {
61
+ if (!Array.isArray(value)) throw new Error("Expected an array of recommendation overrides");
62
+ const recommendations = new Map(DEFAULT_RECOMMENDATIONS.map((item) => [item.id, item]));
63
+ const seen = new Set<string>();
64
+ for (const [index, item] of value.entries()) {
65
+ const entry = `Entry ${index + 1}`;
66
+ if (!item || typeof item !== "object" || Array.isArray(item))
67
+ throw new Error(`${entry} must be an object`);
68
+ const raw = item as Record<string, unknown>;
69
+ for (const key of Object.keys(raw)) {
70
+ if (!FIELDS.has(key)) throw new Error(`${entry}: unknown field ${JSON.stringify(key)}`);
71
+ }
72
+ const id = readString(raw.id, `${entry}.id`);
73
+ if (seen.has(id)) throw new Error(`${entry}: duplicate id ${JSON.stringify(id)}`);
74
+ seen.add(id);
75
+ const label = `${entry} (${JSON.stringify(id)})`;
76
+ const override: Partial<Recommendation> = { id };
77
+ if (raw.when !== undefined) override.when = readString(raw.when, `${label}.when`);
78
+ if (raw.model !== undefined) {
79
+ const model = readString(raw.model, `${label}.model`);
80
+ if (model !== "inherit" && !/^[^\s/]+\/[^\s]+$/.test(model))
81
+ throw new Error(`${label}.model must be inherit or an exact provider/model ID`);
82
+ override.model = model;
83
+ }
84
+ if (raw.thinking !== undefined) {
85
+ const thinking = readString(raw.thinking, `${label}.thinking`);
86
+ if (!THINKING.has(thinking))
87
+ throw new Error(`${label}.thinking must be one of ${[...THINKING].join(", ")}`);
88
+ override.thinking = thinking;
89
+ }
90
+ if (raw.enabled !== undefined) {
91
+ if (typeof raw.enabled !== "boolean") throw new Error(`${label}.enabled must be a boolean`);
92
+ override.enabled = raw.enabled;
93
+ }
94
+ const merged = { ...recommendations.get(id), ...override, id };
95
+ if (!merged.when) throw new Error(`${label}: a new recommendation requires when`);
96
+ if (merged.model === undefined && merged.thinking === undefined)
97
+ throw new Error(`${label}: a new recommendation requires model or thinking`);
98
+ recommendations.set(id, { ...merged, when: merged.when });
99
+ }
100
+ return [...recommendations.values()];
101
+ }
102
+
103
+ function readString(value: unknown, label: string): string {
104
+ if (typeof value !== "string" || !value.trim())
105
+ throw new Error(`${label} must be a nonblank string`);
106
+ return value.trim();
107
+ }
108
+
109
+ function describeRecommendation(recommendation: Recommendation): string {
110
+ const settings = (["model", "thinking"] as const).flatMap((field) => {
111
+ const value = recommendation[field];
112
+ if (value === undefined) return [];
113
+ return value === "inherit"
114
+ ? [`omit ${field} to inherit the parent's ${field === "model" ? "model" : "thinking level"}`]
115
+ : [`prefer ${field}=${JSON.stringify(value)}`];
116
+ });
117
+ return `Subagent recommendation ${JSON.stringify(recommendation.id)} (${recommendation.when}): ${settings.join("; ")}.`;
118
+ }