tau-all-agent 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/answer.ts +115 -85
- package/extensions/branch-term/index.ts +9 -5
- package/extensions/btw.ts +70 -36
- package/extensions/insights.ts +70 -56
- package/extensions/loop.ts +14 -52
- package/extensions/memory.ts +65 -33
- package/extensions/notify.ts +5 -3
- package/extensions/review/fix.ts +10 -16
- package/extensions/review/git.ts +7 -1
- package/extensions/review/index.ts +2 -2
- package/extensions/review/models.ts +12 -2
- package/extensions/review/review.ts +3 -5
- package/extensions/review/runtime.ts +9 -8
- package/extensions/review/triage.ts +2 -4
- package/extensions/sandbox/README.md +7 -0
- package/extensions/sandbox/bash.ts +83 -85
- package/extensions/sandbox/command.ts +15 -1
- package/extensions/sandbox/config.ts +26 -15
- package/extensions/sandbox/index.ts +21 -11
- package/extensions/sandbox/permissions/network.ts +8 -3
- package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
- package/extensions/sandbox/runtime.ts +54 -20
- package/extensions/subagent/README.md +58 -1
- package/extensions/subagent/config.ts +118 -0
- package/extensions/subagent/index.ts +54 -22
- package/extensions/telegram/README.md +2 -0
- package/extensions/telegram/assistant-result.d.mts +4 -1
- package/extensions/telegram/assistant-result.mjs +31 -16
- package/extensions/telegram/daemon.mjs +146 -194
- package/extensions/telegram/index.ts +11 -7
- package/extensions/tool-display-mode.ts +29 -50
- package/extensions/usage/README.md +5 -0
- package/extensions/usage/index.ts +341 -349
- package/extensions/websearch/README.md +8 -1
- package/extensions/websearch/config.ts +2 -1
- package/extensions/websearch/index.ts +18 -9
- package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
- package/extensions/websearch/providers/shared.ts +10 -3
- package/extensions/websearch/types.ts +2 -1
- package/extensions/worktree.ts +33 -89
- package/package.json +1 -1
- package/skills/oracle/SKILL.md +14 -8
- package/skills/oracle/scripts/oracle +43 -19
- package/skills/update-changelog/SKILL.md +8 -4
|
@@ -19,7 +19,7 @@ interface NetworkPermissionEvent {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface NetworkPermissions {
|
|
22
|
-
ask: SandboxAskCallback
|
|
22
|
+
ask(request: Parameters<SandboxAskCallback>[0], signal: AbortSignal): Promise<boolean>;
|
|
23
23
|
clear(): void;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -61,7 +61,11 @@ export function createNetworkPermissions(options: {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const ask
|
|
64
|
+
const ask = async (
|
|
65
|
+
{ host, port }: Parameters<SandboxAskCallback>[0],
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
): Promise<boolean> => {
|
|
68
|
+
if (signal.aborted) return false;
|
|
65
69
|
if (isSuspended()) return true;
|
|
66
70
|
|
|
67
71
|
const normalizedHost = host.toLowerCase();
|
|
@@ -94,7 +98,7 @@ export function createNetworkPermissions(options: {
|
|
|
94
98
|
`Sandbox blocked network access to ${target}`,
|
|
95
99
|
"\nAllow for this session?",
|
|
96
100
|
);
|
|
97
|
-
if (!approved) {
|
|
101
|
+
if (!approved || signal.aborted) {
|
|
98
102
|
recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
|
|
99
103
|
return false;
|
|
100
104
|
}
|
|
@@ -125,6 +129,7 @@ export function createNetworkPermissions(options: {
|
|
|
125
129
|
notify(ctx, `Allowed network domain for this session: ${normalizedHost}`, "info");
|
|
126
130
|
return true;
|
|
127
131
|
} catch (error) {
|
|
132
|
+
if (signal.aborted) return false;
|
|
128
133
|
const ctx = getContext();
|
|
129
134
|
const message = `Sandbox permission prompt failed for ${normalizedHost}: ${error instanceof Error ? error.message : error}`;
|
|
130
135
|
if (ctx) notify(ctx, message, "warning");
|
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
74
|
-
withPermissionContext<T>(
|
|
75
|
-
ctx:
|
|
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:
|
|
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,
|
|
@@ -323,10 +340,10 @@ export function createSandboxRuntime(pi: ExtensionAPI): SandboxRuntime {
|
|
|
323
340
|
return null;
|
|
324
341
|
}
|
|
325
342
|
|
|
326
|
-
const runtimeConfig = toRuntimeConfig(config);
|
|
343
|
+
const runtimeConfig = toRuntimeConfig(config, ctx.cwd);
|
|
327
344
|
|
|
328
345
|
try {
|
|
329
|
-
await SandboxManager.initialize(runtimeConfig,
|
|
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
|
-
/**
|
|
495
|
-
function
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|