@shanepadgett/tau-agent 0.4.0 → 0.5.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.
package/docs/subagents.md CHANGED
@@ -9,7 +9,7 @@ Tau's `subagent` tool delegates one focused task to an isolated child Pi session
9
9
  | **User (global)** | `~/.pi/agent/tau/agents/*.md` | You want the agent in every project |
10
10
  | **Project** | nearest trusted `.pi/tau/agents/*.md` | Repo-specific helpers |
11
11
 
12
- Precedence: **project overrides user**, which overrides Tau's built-ins (`scout`, `web-research`). Duplicate names in one scope are invalid.
12
+ Precedence: **project overrides user**, which overrides Tau's built-ins (`scout`, `context-maintenance`, `web-research`). Duplicate names in one scope are invalid.
13
13
 
14
14
  ## Definition format
15
15
 
@@ -46,12 +46,14 @@ Named tools and configured models must exist in the normally loaded child Pi env
46
46
 
47
47
  - Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
48
48
  - Children do not receive the parent conversation.
49
+ - Children load only the extensions that own their declared tools. Unrelated extension hooks do not run in child sessions.
49
50
  - At most four children run at once; extra calls wait in order.
50
51
  - Returned text is capped (50 KB / 2,000 lines); full truncated output is saved to a private temp file.
51
52
 
52
53
  ## Built-ins
53
54
 
54
55
  - `scout` — local exploration with `read`, `grep`, `find`, `ls`
56
+ - `context-maintenance` — repository context reconciliation with interactive user approval
55
57
  - `web-research` — `websearch`, `codesearch`, `webfetch`
56
58
 
57
59
  Ask Tau to delegate, or let it call `subagent` with an agent name and task.
@@ -0,0 +1,20 @@
1
+ # Context
2
+
3
+ Context stores reusable repository work scopes in `.pi/contexts`. Folder names become selector tabs, TOML files become concepts, and TOML sections become selectable entries.
4
+
5
+ Use `/context` to select entries and inject their files through Tau autoread. Use `/context-manage <idea>` to start an isolated maintenance agent that researches the catalog, presents proposed operations for approval, accepts feedback, and applies only selected operations.
6
+
7
+ ```toml
8
+ name = "Player"
9
+ description = "Player-owned gameplay systems"
10
+
11
+ [input]
12
+ description = "Input mapping and command handling"
13
+ files = ["src/player/input.ts"]
14
+
15
+ [movement]
16
+ description = "Player locomotion and collision"
17
+ files = ["src/player/movement.ts"]
18
+ ```
19
+
20
+ For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries.
@@ -0,0 +1,258 @@
1
+ import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
4
+ import { parse, stringify } from "smol-toml";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+
7
+ export interface ContextEntry {
8
+ id: string;
9
+ tab: string;
10
+ concept: string;
11
+ conceptName: string;
12
+ conceptDescription: string;
13
+ name: string;
14
+ description: string;
15
+ files: string[];
16
+ path: string;
17
+ }
18
+
19
+ export interface ContextProposal {
20
+ tab: string;
21
+ concept: string;
22
+ conceptName: string;
23
+ conceptDescription: string;
24
+ entry: string;
25
+ description: string;
26
+ files: string[];
27
+ }
28
+
29
+ export type ContextOperation =
30
+ | (ContextProposal & { id: string; kind: "create"; reason: string })
31
+ | {
32
+ id: string;
33
+ kind: "add-files" | "remove-files";
34
+ tab: string;
35
+ concept: string;
36
+ entry: string;
37
+ files: string[];
38
+ reason: string;
39
+ }
40
+ | {
41
+ id: string;
42
+ kind: "replace-file";
43
+ tab: string;
44
+ concept: string;
45
+ entry: string;
46
+ from: string;
47
+ to: string;
48
+ reason: string;
49
+ };
50
+
51
+ export async function pathExists(path: string): Promise<boolean> {
52
+ try {
53
+ await access(path);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export async function findProjectRoot(cwd: string): Promise<string> {
61
+ const home = resolve(homedir());
62
+ let current = resolve(cwd);
63
+ let gitRoot: string | undefined;
64
+ while (true) {
65
+ if (await pathExists(join(current, ".pi", "contexts"))) return current;
66
+ if (!gitRoot && (await pathExists(join(current, ".git")))) gitRoot = current;
67
+ const parent = dirname(current);
68
+ if (parent === current || current === home) break;
69
+ current = parent;
70
+ }
71
+ return gitRoot ?? resolve(cwd);
72
+ }
73
+
74
+ export function validSlug(value: string, label: string): string {
75
+ const slug = value.trim();
76
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`${label} must use lowercase kebab-case: ${value}`);
77
+ return slug;
78
+ }
79
+
80
+ export function normalizeProjectPath(root: string, input: string): string {
81
+ const absolute = resolve(root, input.trim().replace(/^@/, ""));
82
+ const path = relative(root, absolute).split(sep).join("/");
83
+ if (!path || path === "." || path === ".." || path.startsWith("../"))
84
+ throw new Error(`Path must stay inside project: ${input}`);
85
+ return path;
86
+ }
87
+
88
+ function sortedUnique(values: readonly string[]): string[] {
89
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
90
+ }
91
+
92
+ export async function requireFiles(root: string, inputs: readonly string[]): Promise<string[]> {
93
+ const files = sortedUnique(inputs.map((input) => normalizeProjectPath(root, input)));
94
+ for (const file of files) {
95
+ try {
96
+ if (!(await stat(join(root, file))).isFile()) throw new Error();
97
+ } catch {
98
+ throw new Error(`Context file does not exist: ${file}`);
99
+ }
100
+ }
101
+ return files;
102
+ }
103
+
104
+ export async function loadContextEntries(root: string): Promise<ContextEntry[]> {
105
+ const contextsRoot = join(root, ".pi", "contexts");
106
+ if (!(await pathExists(contextsRoot))) return [];
107
+ const tabs = (await readdir(contextsRoot, { withFileTypes: true }))
108
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
109
+ .sort((a, b) => a.name.localeCompare(b.name));
110
+ const result: ContextEntry[] = [];
111
+ for (const tabEntry of tabs) {
112
+ const tab = validSlug(tabEntry.name, "Context tab");
113
+ const files = (await readdir(join(contextsRoot, tab), { withFileTypes: true }))
114
+ .filter((entry) => entry.isFile() && extname(entry.name) === ".toml")
115
+ .sort((a, b) => a.name.localeCompare(b.name));
116
+ for (const file of files) {
117
+ const path = join(contextsRoot, tab, file.name);
118
+ const concept = validSlug(basename(file.name, ".toml"), "Context concept");
119
+ const raw = parse(await readFile(path, "utf8")) as Record<string, unknown>;
120
+ const conceptName = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : concept;
121
+ const conceptDescription = typeof raw.description === "string" ? raw.description.trim() : "";
122
+ for (const [name, value] of Object.entries(raw)) {
123
+ if (name === "name" || name === "description") continue;
124
+ if (!value || typeof value !== "object" || Array.isArray(value))
125
+ throw new Error(`Invalid context entry: ${path} [${name}]`);
126
+ const record = value as Record<string, unknown>;
127
+ if (
128
+ typeof record.description !== "string" ||
129
+ !record.description.trim() ||
130
+ !Array.isArray(record.files) ||
131
+ record.files.length === 0 ||
132
+ record.files.some((item) => typeof item !== "string")
133
+ )
134
+ throw new Error(`Invalid context entry: ${path} [${name}]`);
135
+ const entry = validSlug(name, "Context entry");
136
+ result.push({
137
+ id: `${tab}/${concept}/${entry}`,
138
+ tab,
139
+ concept,
140
+ conceptName,
141
+ conceptDescription,
142
+ name: entry,
143
+ description: record.description.trim(),
144
+ files: sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item))),
145
+ path,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ return result;
151
+ }
152
+
153
+ export async function writeContextEntry(root: string, proposal: ContextProposal, replace: boolean): Promise<void> {
154
+ const tab = validSlug(proposal.tab, "Context tab");
155
+ const concept = validSlug(proposal.concept, "Context concept");
156
+ const entry = validSlug(proposal.entry, "Context entry");
157
+ const conceptName = proposal.conceptName.trim();
158
+ const description = proposal.description.trim();
159
+ if (!conceptName) throw new Error("Context concept name is required");
160
+ if (!description) throw new Error("Context entry description is required");
161
+ const files = await requireFiles(root, proposal.files);
162
+ const path = join(root, ".pi", "contexts", tab, `${concept}.toml`);
163
+ await withFileMutationQueue(path, async () => {
164
+ let raw: Record<string, unknown> = {};
165
+ if (await pathExists(path)) raw = parse(await readFile(path, "utf8")) as Record<string, unknown>;
166
+ if (!replace && raw[entry] !== undefined)
167
+ throw new Error(`Context entry already exists: ${tab}/${concept}/${entry}`);
168
+ if (raw.name === undefined) raw.name = conceptName;
169
+ if (raw.description === undefined) raw.description = proposal.conceptDescription.trim();
170
+ raw[entry] = { description, files };
171
+ await mkdir(dirname(path), { recursive: true });
172
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
173
+ await writeFile(temporary, stringify(raw), "utf8");
174
+ await rename(temporary, path);
175
+ });
176
+ }
177
+
178
+ export async function updateContextFiles(
179
+ root: string,
180
+ tabInput: string,
181
+ conceptInput: string,
182
+ entryInput: string,
183
+ paths: readonly string[],
184
+ action: "add" | "remove",
185
+ ): Promise<string[]> {
186
+ const tab = validSlug(tabInput, "Context tab");
187
+ const concept = validSlug(conceptInput, "Context concept");
188
+ const entry = validSlug(entryInput, "Context entry");
189
+ const current = (await loadContextEntries(root)).find((item) => item.id === `${tab}/${concept}/${entry}`);
190
+ if (!current) throw new Error(`Unknown context entry: ${tab}/${concept}/${entry}`);
191
+ const changed =
192
+ action === "add" ? await requireFiles(root, paths) : paths.map((path) => normalizeProjectPath(root, path));
193
+ const changedSet = new Set(changed);
194
+ const files =
195
+ action === "add" ? [...current.files, ...changed] : current.files.filter((file) => !changedSet.has(file));
196
+ if (files.length === 0) throw new Error("Context entries must contain at least one file");
197
+ await writeContextEntry(
198
+ root,
199
+ {
200
+ tab,
201
+ concept,
202
+ conceptName: current.conceptName,
203
+ conceptDescription: current.conceptDescription,
204
+ entry,
205
+ description: current.description,
206
+ files,
207
+ },
208
+ true,
209
+ );
210
+ return sortedUnique(changed);
211
+ }
212
+
213
+ export async function replaceContextFile(
214
+ root: string,
215
+ tab: string,
216
+ concept: string,
217
+ entry: string,
218
+ fromInput: string,
219
+ toInput: string,
220
+ ): Promise<void> {
221
+ const from = normalizeProjectPath(root, fromInput);
222
+ const [to] = await requireFiles(root, [toInput]);
223
+ const current = (await loadContextEntries(root)).find((item) => item.id === `${tab}/${concept}/${entry}`);
224
+ if (!current) throw new Error(`Unknown context entry: ${tab}/${concept}/${entry}`);
225
+ if (!current.files.includes(from)) throw new Error(`Context entry does not contain: ${from}`);
226
+ await writeContextEntry(
227
+ root,
228
+ {
229
+ tab: current.tab,
230
+ concept: current.concept,
231
+ conceptName: current.conceptName,
232
+ conceptDescription: current.conceptDescription,
233
+ entry: current.name,
234
+ description: current.description,
235
+ files: current.files.map((file) => (file === from ? to : file)),
236
+ },
237
+ true,
238
+ );
239
+ }
240
+
241
+ export async function applyContextOperation(root: string, operation: ContextOperation): Promise<void> {
242
+ if (operation.kind === "create") {
243
+ await writeContextEntry(root, operation, false);
244
+ return;
245
+ }
246
+ if (operation.kind === "replace-file") {
247
+ await replaceContextFile(root, operation.tab, operation.concept, operation.entry, operation.from, operation.to);
248
+ return;
249
+ }
250
+ await updateContextFiles(
251
+ root,
252
+ operation.tab,
253
+ operation.concept,
254
+ operation.entry,
255
+ operation.files,
256
+ operation.kind === "add-files" ? "add" : "remove",
257
+ );
258
+ }
@@ -0,0 +1,476 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { emitTauEvent } from "../../shared/events.ts";
6
+ import { createInjectedContext } from "../../shared/injected-context.ts";
7
+ import { discoverAgents } from "../subagent/agents.ts";
8
+ import { extensionPathsForTools, runSubagent } from "../subagent/run.ts";
9
+ import { ContextPanel, ProposalPanel, type ProposalReviewDecision } from "./panel.ts";
10
+ import {
11
+ applyContextOperation,
12
+ findProjectRoot,
13
+ loadContextEntries,
14
+ normalizeProjectPath,
15
+ pathExists,
16
+ requireFiles,
17
+ updateContextFiles,
18
+ validSlug,
19
+ writeContextEntry,
20
+ type ContextEntry,
21
+ type ContextOperation,
22
+ } from "./definitions.ts";
23
+
24
+ const identityParams = Type.Object(
25
+ { tab: Type.String(), concept: Type.String(), entry: Type.String() },
26
+ { additionalProperties: false },
27
+ );
28
+ const membershipParams = Type.Object(
29
+ {
30
+ tab: Type.String(),
31
+ concept: Type.String(),
32
+ entry: Type.String(),
33
+ paths: Type.Array(Type.String(), { minItems: 1 }),
34
+ },
35
+ { additionalProperties: false },
36
+ );
37
+ const createParams = Type.Object(
38
+ {
39
+ tab: Type.String(),
40
+ concept: Type.String(),
41
+ conceptName: Type.String(),
42
+ conceptDescription: Type.String(),
43
+ entry: Type.String(),
44
+ description: Type.String(),
45
+ paths: Type.Array(Type.String(), { minItems: 1 }),
46
+ },
47
+ { additionalProperties: false },
48
+ );
49
+
50
+ const operationIdentity = {
51
+ id: Type.String({ minLength: 1 }),
52
+ tab: Type.String(),
53
+ concept: Type.String(),
54
+ entry: Type.String(),
55
+ reason: Type.String({ minLength: 1 }),
56
+ };
57
+ const reviewParams = Type.Object(
58
+ {
59
+ operations: Type.Array(
60
+ Type.Union([
61
+ Type.Object({
62
+ ...operationIdentity,
63
+ kind: Type.Literal("create"),
64
+ conceptName: Type.String(),
65
+ conceptDescription: Type.String(),
66
+ description: Type.String(),
67
+ files: Type.Array(Type.String(), { minItems: 1 }),
68
+ }),
69
+ Type.Object({
70
+ ...operationIdentity,
71
+ kind: Type.Union([Type.Literal("add-files"), Type.Literal("remove-files")]),
72
+ files: Type.Array(Type.String(), { minItems: 1 }),
73
+ }),
74
+ Type.Object({
75
+ ...operationIdentity,
76
+ kind: Type.Literal("replace-file"),
77
+ from: Type.String(),
78
+ to: Type.String(),
79
+ }),
80
+ ]),
81
+ { minItems: 1 },
82
+ ),
83
+ },
84
+ { additionalProperties: false },
85
+ );
86
+
87
+ function result(value: unknown) {
88
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], details: value };
89
+ }
90
+
91
+ let reviewQueue = Promise.resolve();
92
+
93
+ async function withReviewLock<T>(task: () => Promise<T>): Promise<T> {
94
+ const previous = reviewQueue;
95
+ let release = () => {};
96
+ reviewQueue = new Promise<void>((resolve) => {
97
+ release = resolve;
98
+ });
99
+ await previous;
100
+ try {
101
+ return await task();
102
+ } finally {
103
+ release();
104
+ }
105
+ }
106
+
107
+ function parseChangedFiles(raw: string): Array<{ path: string; status: string; renamedFrom?: string }> {
108
+ const fields = raw.split("\0").filter(Boolean);
109
+ const changes: Array<{ path: string; status: string; renamedFrom?: string }> = [];
110
+ for (let index = 0; index < fields.length; index += 1) {
111
+ const field = fields[index];
112
+ if (!field) continue;
113
+ const status = field.slice(0, 2);
114
+ const path = field.slice(3);
115
+ if (status.includes("R") || status.includes("C")) {
116
+ const renamedFrom = fields[index + 1];
117
+ if (renamedFrom) index += 1;
118
+ changes.push({ path, status, ...(renamedFrom ? { renamedFrom } : {}) });
119
+ } else changes.push({ path, status });
120
+ }
121
+ return changes;
122
+ }
123
+
124
+ async function validateContextOperations(
125
+ root: string,
126
+ operations: readonly ContextOperation[],
127
+ ): Promise<ContextOperation[]> {
128
+ const entries = await loadContextEntries(root);
129
+ const ids = new Set(entries.map((entry) => entry.id));
130
+ const normalized: ContextOperation[] = [];
131
+ for (const operation of operations) {
132
+ const id = operation.id.trim();
133
+ const reason = operation.reason.trim();
134
+ if (!id) throw new Error("Context operation id is required");
135
+ if (!reason) throw new Error(`Context operation reason is required: ${id}`);
136
+ const tab = validSlug(operation.tab, "Context tab");
137
+ const concept = validSlug(operation.concept, "Context concept");
138
+ const entry = validSlug(operation.entry, "Context entry");
139
+ const contextId = `${tab}/${concept}/${entry}`;
140
+ if (operation.kind === "create") {
141
+ if (ids.has(contextId)) throw new Error(`Context entry already exists: ${contextId}`);
142
+ if (!operation.conceptName.trim()) throw new Error(`Context concept name is required: ${id}`);
143
+ if (!operation.description.trim()) throw new Error(`Context entry description is required: ${id}`);
144
+ ids.add(contextId);
145
+ normalized.push({
146
+ ...operation,
147
+ id,
148
+ reason,
149
+ tab,
150
+ concept,
151
+ entry,
152
+ conceptName: operation.conceptName.trim(),
153
+ conceptDescription: operation.conceptDescription.trim(),
154
+ description: operation.description.trim(),
155
+ files: await requireFiles(root, operation.files),
156
+ });
157
+ continue;
158
+ }
159
+ const current = entries.find((item) => item.id === contextId);
160
+ if (!current) throw new Error(`Unknown context entry: ${contextId}`);
161
+ if (operation.kind === "replace-file") {
162
+ const from = normalizeProjectPath(root, operation.from);
163
+ if (!current.files.includes(from)) throw new Error(`Context entry does not contain: ${from}`);
164
+ const [to] = await requireFiles(root, [operation.to]);
165
+ normalized.push({ ...operation, id, reason, tab, concept, entry, from, to });
166
+ continue;
167
+ }
168
+ const files =
169
+ operation.kind === "add-files"
170
+ ? await requireFiles(root, operation.files)
171
+ : operation.files.map((path) => normalizeProjectPath(root, path));
172
+ if (operation.kind === "remove-files" && current.files.filter((path) => !files.includes(path)).length === 0)
173
+ throw new Error(`Context operation would empty entry: ${contextId}`);
174
+ if (operation.kind === "remove-files") {
175
+ const unknown = files.find((path) => !current.files.includes(path));
176
+ if (unknown) throw new Error(`Context entry does not contain: ${unknown}`);
177
+ }
178
+ normalized.push({ ...operation, id, reason, tab, concept, entry, files });
179
+ }
180
+ return normalized;
181
+ }
182
+
183
+ async function maintain(pi: ExtensionAPI, ctx: ExtensionCommandContext, root: string, request: string): Promise<void> {
184
+ const discovery = await discoverAgents(root, ctx.isProjectTrusted());
185
+ const definition = discovery.agents.get("context-maintenance");
186
+ if (!definition) throw new Error("Built-in context-maintenance subagent is unavailable");
187
+ const existing = await loadContextEntries(root);
188
+ const controller = new AbortController();
189
+ const response = await runSubagent({
190
+ definition,
191
+ extensionPaths: extensionPathsForTools(pi, definition.tools),
192
+ task: `Maintain repository context for this request:\n${request}\n\nExisting entries:\n${JSON.stringify(
193
+ existing.map(({ id, description, files }) => ({ id, description, files })),
194
+ null,
195
+ 2,
196
+ )}`,
197
+ ctx,
198
+ thinkingLevel: pi.getThinkingLevel(),
199
+ signal: controller.signal,
200
+ });
201
+ if (response.details.status !== "completed") throw new Error(response.content);
202
+ }
203
+
204
+ export default function contextExtension(pi: ExtensionAPI): void {
205
+ let active: ContextEntry[] = [];
206
+
207
+ pi.registerCommand("context", {
208
+ description: "Select repository context entries and inject their files",
209
+ handler: async (_args, ctx) => {
210
+ if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
211
+ ctx.ui.notify("/context requires a trusted TUI project", "warning");
212
+ return;
213
+ }
214
+ await ctx.waitForIdle();
215
+ const root = await findProjectRoot(ctx.cwd);
216
+ const entries = await loadContextEntries(root);
217
+ const firstEntry = entries[0];
218
+ if (!firstEntry) {
219
+ ctx.ui.notify(`No context entries found in ${join(root, ".pi", "contexts")}`, "warning");
220
+ return;
221
+ }
222
+ const selected = await ctx.ui.custom<ContextEntry[] | undefined>(
223
+ (tui, theme, _keys, done) => new ContextPanel(tui, theme, entries, done),
224
+ {
225
+ overlay: true,
226
+ overlayOptions: {
227
+ anchor: "top-center",
228
+ width: "70%",
229
+ minWidth: 64,
230
+ maxHeight: "80%",
231
+ margin: 2,
232
+ },
233
+ },
234
+ );
235
+ if (!selected?.length) return;
236
+ active = selected;
237
+ pi.appendEntry("tau.context-selection", { ids: selected.map((entry) => entry.id) });
238
+ const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
239
+ pi.sendMessage(
240
+ createInjectedContext(
241
+ "Treat the autoread files as the authoritative project context and current snapshots. Do not reread them or search for coverage around them. Start work from them immediately. Explore outside them only when the user's request or concrete evidence in those files requires missing code or information.",
242
+ { source: "context", title: "Project context" },
243
+ ),
244
+ );
245
+ emitTauEvent(pi, "tau:autoread.requested", {
246
+ source: "context",
247
+ title: "Project context",
248
+ cwd: root,
249
+ batchId: randomUUID(),
250
+ files: files.map((path) => ({ path })),
251
+ });
252
+ },
253
+ });
254
+
255
+ pi.registerCommand("context-manage", {
256
+ description: "Research, review, and maintain repository context entries",
257
+ handler: async (args, ctx) => {
258
+ if (!args.trim()) {
259
+ ctx.ui.notify("Usage: /context-manage <context idea>", "warning");
260
+ return;
261
+ }
262
+ if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
263
+ ctx.ui.notify("/context-manage requires a trusted TUI project", "warning");
264
+ return;
265
+ }
266
+ await ctx.waitForIdle();
267
+ const root = await findProjectRoot(ctx.cwd);
268
+ ctx.ui.setStatus("context-manage", "maintaining contexts");
269
+ try {
270
+ await maintain(pi, ctx, root, args.trim());
271
+ } catch (error) {
272
+ ctx.ui.notify(
273
+ `Context maintenance failed: ${error instanceof Error ? error.message : String(error)}`,
274
+ "error",
275
+ );
276
+ return;
277
+ } finally {
278
+ ctx.ui.setStatus("context-manage", undefined);
279
+ }
280
+ },
281
+ });
282
+
283
+ pi.registerTool({
284
+ name: "context_changes",
285
+ label: "Context Changes",
286
+ description: "Report uncommitted repository paths for context maintenance.",
287
+ parameters: Type.Object({}, { additionalProperties: false }),
288
+ async execute(_id, _params, signal, _update, ctx) {
289
+ if (!ctx.isProjectTrusted()) throw new Error("Context changes require a trusted project");
290
+ const rootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.cwd, signal });
291
+ if (rootResult.code !== 0) throw new Error("No Git repository found");
292
+ const root = rootResult.stdout.trim();
293
+ const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
294
+ cwd: root,
295
+ signal,
296
+ });
297
+ if (status.code !== 0) throw new Error(status.stderr.trim() || "Unable to read Git status");
298
+ return result({ root, changes: parseChangedFiles(status.stdout) });
299
+ },
300
+ });
301
+ pi.registerTool({
302
+ name: "context_review",
303
+ label: "Context Review",
304
+ description: "Present context maintenance operations for user approval and apply only selected operations.",
305
+ parameters: reviewParams,
306
+ executionMode: "sequential",
307
+ async execute(_id, params, _signal, _update, ctx) {
308
+ if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("Context review requires TUI mode");
309
+ if (!ctx.isProjectTrusted()) throw new Error("Context review requires a trusted project");
310
+ const rawOperations = params.operations as ContextOperation[];
311
+ if (new Set(rawOperations.map((operation) => operation.id)).size !== rawOperations.length)
312
+ throw new Error("Context operation ids must be unique");
313
+ const root = await findProjectRoot(ctx.cwd);
314
+ const operations = await validateContextOperations(root, rawOperations);
315
+ if (new Set(operations.map((operation) => operation.id)).size !== operations.length)
316
+ throw new Error("Context operation ids must be unique after trimming");
317
+ ctx.ui.setWorkingVisible(false);
318
+ try {
319
+ return await withReviewLock(async () => {
320
+ const decision = await ctx.ui.custom<ProposalReviewDecision>(
321
+ (tui, theme, _keys, done) => new ProposalPanel(tui, theme, operations, done),
322
+ );
323
+ if (decision.kind === "rejected") return result(decision);
324
+ if (decision.kind === "feedback") {
325
+ const feedback = await ctx.ui.editor(
326
+ decision.scope === "batch" ? "Context proposal feedback" : `Feedback for ${decision.operationId}`,
327
+ "",
328
+ );
329
+ return result(
330
+ feedback?.trim()
331
+ ? { ...decision, text: feedback.trim() }
332
+ : { kind: "rejected", reason: "Feedback cancelled" },
333
+ );
334
+ }
335
+ for (const operation of decision.operations) await applyContextOperation(root, operation);
336
+ return result({
337
+ kind: "applied" as const,
338
+ operationIds: decision.operations.map((operation) => operation.id),
339
+ });
340
+ });
341
+ } finally {
342
+ ctx.ui.setWorkingVisible(true);
343
+ }
344
+ },
345
+ });
346
+ pi.registerTool({
347
+ name: "context_list",
348
+ label: "Context List",
349
+ description: "List repository context tabs, concepts, entries, and file counts.",
350
+ parameters: Type.Object({}, { additionalProperties: false }),
351
+ async execute(_id, _params, _signal, _update, ctx) {
352
+ const root = await findProjectRoot(ctx.cwd);
353
+ return result(
354
+ (await loadContextEntries(root)).map(({ id, description, files }) => ({
355
+ id,
356
+ description,
357
+ fileCount: files.length,
358
+ })),
359
+ );
360
+ },
361
+ });
362
+ pi.registerTool({
363
+ name: "context_get",
364
+ label: "Context Get",
365
+ description: "Get one repository context entry and its files.",
366
+ parameters: identityParams,
367
+ async execute(_id, params, _signal, _update, ctx) {
368
+ const root = await findProjectRoot(ctx.cwd);
369
+ const id = `${params.tab}/${params.concept}/${params.entry}`;
370
+ const entry = (await loadContextEntries(root)).find((item) => item.id === id);
371
+ if (!entry) throw new Error(`Unknown context entry: ${id}`);
372
+ return result(entry);
373
+ },
374
+ });
375
+ pi.registerTool({
376
+ name: "context_create",
377
+ label: "Context Create",
378
+ description: "Create one validated repository context entry.",
379
+ parameters: createParams,
380
+ async execute(_id, params, _signal, _update, ctx) {
381
+ const root = await findProjectRoot(ctx.cwd);
382
+ await writeContextEntry(root, { ...params, files: params.paths }, false);
383
+ return result({ created: `${params.tab}/${params.concept}/${params.entry}` });
384
+ },
385
+ });
386
+ pi.registerTool({
387
+ name: "context_add_files",
388
+ label: "Context Add Files",
389
+ description: "Add existing project files to one context entry.",
390
+ parameters: membershipParams,
391
+ async execute(_id, params, _signal, _update, ctx) {
392
+ const root = await findProjectRoot(ctx.cwd);
393
+ return result({
394
+ added: await updateContextFiles(root, params.tab, params.concept, params.entry, params.paths, "add"),
395
+ });
396
+ },
397
+ });
398
+ pi.registerTool({
399
+ name: "context_remove_files",
400
+ label: "Context Remove Files",
401
+ description: "Remove project files from one context entry.",
402
+ parameters: membershipParams,
403
+ async execute(_id, params, _signal, _update, ctx) {
404
+ const root = await findProjectRoot(ctx.cwd);
405
+ return result({
406
+ removed: await updateContextFiles(root, params.tab, params.concept, params.entry, params.paths, "remove"),
407
+ });
408
+ },
409
+ });
410
+ pi.registerTool({
411
+ name: "context_check",
412
+ label: "Context Check",
413
+ description: "Report context memberships and existence for exact project paths.",
414
+ parameters: Type.Object({ paths: Type.Array(Type.String(), { minItems: 1 }) }, { additionalProperties: false }),
415
+ async execute(_id, params, _signal, _update, ctx) {
416
+ const root = await findProjectRoot(ctx.cwd);
417
+ const entries = await loadContextEntries(root);
418
+ return result(
419
+ await Promise.all(
420
+ params.paths.map(async (input) => {
421
+ const path = normalizeProjectPath(root, input);
422
+ return {
423
+ path,
424
+ exists: await pathExists(join(root, path)),
425
+ memberships: entries.filter((entry) => entry.files.includes(path)).map((entry) => entry.id),
426
+ };
427
+ }),
428
+ ),
429
+ );
430
+ },
431
+ });
432
+ pi.registerTool({
433
+ name: "context_audit",
434
+ label: "Context Audit",
435
+ description: "Report context paths that no longer exist.",
436
+ parameters: Type.Object({}, { additionalProperties: false }),
437
+ async execute(_id, _params, _signal, _update, ctx) {
438
+ const root = await findProjectRoot(ctx.cwd);
439
+ const entries = await loadContextEntries(root);
440
+ const stale = (
441
+ await Promise.all(
442
+ entries.flatMap((entry) =>
443
+ entry.files.map(async (path) =>
444
+ (await pathExists(join(root, path))) ? undefined : { entry: entry.id, path },
445
+ ),
446
+ ),
447
+ )
448
+ ).filter((item) => item !== undefined);
449
+ return result({ stale });
450
+ },
451
+ });
452
+
453
+ pi.on("session_start", async (_event, ctx) => {
454
+ const root = await findProjectRoot(ctx.cwd);
455
+ const entries = await loadContextEntries(root);
456
+ let selectionData: unknown;
457
+ for (const entry of ctx.sessionManager.getBranch()) {
458
+ if (entry.type === "custom" && entry.customType === "tau.context-selection") selectionData = entry.data;
459
+ }
460
+ const ids =
461
+ typeof selectionData === "object" &&
462
+ selectionData !== null &&
463
+ "ids" in selectionData &&
464
+ Array.isArray(selectionData.ids)
465
+ ? selectionData.ids.filter((id: unknown): id is string => typeof id === "string")
466
+ : [];
467
+ active = entries.filter((entry) => ids.includes(entry.id));
468
+ });
469
+ pi.on("before_agent_start", (event) =>
470
+ active.length
471
+ ? {
472
+ systemPrompt: `${event.systemPrompt}\n\nTreat the autoread files as the authoritative project context and current snapshots. Do not reread them or search for coverage around them. Start work from them immediately. Explore outside them only when the user's request or concrete evidence in those files requires missing code or information.`,
473
+ }
474
+ : undefined,
475
+ );
476
+ }
@@ -0,0 +1,237 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, getKeybindings, Key, matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import { bindingHint, rawHint, SelectableList, Tabs, ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
4
+ import type { ContextEntry, ContextOperation } from "./definitions.ts";
5
+
6
+ type SelectableEntry = ContextEntry & { id: string };
7
+
8
+ export class ContextPanel implements Component {
9
+ private readonly tui: TUI;
10
+ private readonly theme: Theme;
11
+ private readonly tabs: Tabs;
12
+ private readonly panel: ToolPanel;
13
+ private readonly config: ToolPanelConfig;
14
+ private readonly lists = new Map<string, SelectableList<SelectableEntry>>();
15
+ private readonly selected = new Map<string, readonly SelectableEntry[]>();
16
+ private readonly done: (result: ContextEntry[] | undefined) => void;
17
+ private current: ContextEntry;
18
+
19
+ constructor(
20
+ tui: TUI,
21
+ theme: Theme,
22
+ entries: readonly ContextEntry[],
23
+ done: (result: ContextEntry[] | undefined) => void,
24
+ ) {
25
+ const firstEntry = entries[0];
26
+ if (!firstEntry) throw new Error("Context panel requires at least one entry");
27
+ this.tui = tui;
28
+ this.theme = theme;
29
+ this.done = done;
30
+ this.current = firstEntry;
31
+ const tabNames = [...new Set(entries.map((entry) => entry.tab))].sort();
32
+ this.tabs = new Tabs(
33
+ theme,
34
+ tabNames.map((tab) => {
35
+ const items = entries.filter((entry) => entry.tab === tab);
36
+ const list = new SelectableList(theme, {
37
+ items,
38
+ emptyMessage: "No context entries.",
39
+ selection: { kind: "multi" },
40
+ filter: { searchText: (item) => `${item.conceptName} ${item.name} ${item.description}` },
41
+ actions: [],
42
+ maxVisible: 10,
43
+ renderItem: (item, state, width) => [
44
+ truncateToWidth(
45
+ `${state.active ? theme.bold(item.conceptName) : item.conceptName} · ${item.name} ${theme.fg("dim", item.description)}`,
46
+ width,
47
+ "…",
48
+ ),
49
+ ],
50
+ onResult: () => {},
51
+ onSelectionChange: (value) => {
52
+ this.selected.set(tab, value);
53
+ this.sync();
54
+ },
55
+ });
56
+ this.lists.set(tab, list);
57
+ return { id: tab, label: tab, count: items.length, body: list, getKeyHints: () => list.getKeyHints() };
58
+ }),
59
+ tabNames[0] ?? "",
60
+ );
61
+ this.config = {
62
+ title: "Project context",
63
+ secondary: this.secondary(),
64
+ body: this.body(),
65
+ footer: { kind: "hints", hints: this.hints() },
66
+ border: "box",
67
+ };
68
+ this.panel = new ToolPanel(theme, this.config);
69
+ }
70
+
71
+ handleInput(data: string): void {
72
+ const list = this.activeList();
73
+ if (!list?.isFilterFocused()) {
74
+ const keys = getKeybindings();
75
+ if (matchesKey(data, Key.ctrl("c"))) {
76
+ for (const [tab, tabList] of this.lists) {
77
+ tabList.setSelectedIds([]);
78
+ this.selected.set(tab, []);
79
+ }
80
+ this.sync();
81
+ return;
82
+ }
83
+ if (keys.matches(data, "tui.select.confirm")) {
84
+ this.done([...this.selected.values()].flatMap((items) => [...items]));
85
+ return;
86
+ }
87
+ if (keys.matches(data, "tui.select.cancel")) {
88
+ this.done(undefined);
89
+ return;
90
+ }
91
+ }
92
+ this.tabs.handleInput(data);
93
+ this.current = this.activeList()?.getCurrentItem() ?? this.current;
94
+ this.sync();
95
+ }
96
+
97
+ render(width: number): string[] {
98
+ return this.panel.render(width);
99
+ }
100
+ invalidate(): void {
101
+ this.panel.invalidate();
102
+ }
103
+
104
+ private activeList(): SelectableList<SelectableEntry> | undefined {
105
+ return this.lists.get(this.tabs.getActiveId());
106
+ }
107
+ private body(): Component {
108
+ return {
109
+ render: (width) => [
110
+ ...this.tabs.render(width),
111
+ "",
112
+ ...this.current.files.map((file) => truncateToWidth(this.theme.fg("muted", `• ${file}`), width, "…")),
113
+ ],
114
+ invalidate: () => this.tabs.invalidate(),
115
+ };
116
+ }
117
+ private secondary(): string {
118
+ return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected`;
119
+ }
120
+ private hints() {
121
+ const list = this.activeList();
122
+ return list?.isFilterFocused()
123
+ ? list.getKeyHints()
124
+ : [
125
+ ...this.tabs.getKeyHints(),
126
+ rawHint("ctrl+c", "clear all"),
127
+ bindingHint("tui.select.confirm", "inject"),
128
+ bindingHint("tui.select.cancel", "cancel"),
129
+ ];
130
+ }
131
+ private sync(): void {
132
+ this.config.secondary = this.secondary();
133
+ this.config.footer = { kind: "hints", hints: this.hints() };
134
+ this.tui.requestRender();
135
+ }
136
+ }
137
+
138
+ export type ProposalReviewDecision =
139
+ | { kind: "approve"; operations: ContextOperation[] }
140
+ | { kind: "feedback"; scope: "batch"; selectedIds: string[] }
141
+ | { kind: "feedback"; scope: "operation"; operationId: string; selectedIds: string[] }
142
+ | { kind: "rejected" };
143
+
144
+ export class ProposalPanel implements Component {
145
+ private readonly list: SelectableList<ContextOperation>;
146
+ private readonly panel: ToolPanel;
147
+ private readonly done: (result: ProposalReviewDecision) => void;
148
+ private selected: readonly ContextOperation[] = [];
149
+ private current: ContextOperation;
150
+ constructor(
151
+ tui: TUI,
152
+ theme: Theme,
153
+ proposals: readonly ContextOperation[],
154
+ done: (result: ProposalReviewDecision) => void,
155
+ ) {
156
+ const first = proposals[0];
157
+ if (!first) throw new Error("Proposal panel requires at least one operation");
158
+ this.done = done;
159
+ this.current = first;
160
+ const items = [...proposals];
161
+ this.list = new SelectableList(theme, {
162
+ items,
163
+ emptyMessage: "No proposals.",
164
+ selection: { kind: "multi", primaryLabel: "apply selected" },
165
+ actions: [],
166
+ cancelLabel: "cancel",
167
+ maxVisible: 12,
168
+ renderItem: (item, _state, width) => [
169
+ truncateToWidth(
170
+ `${theme.fg("accent", item.kind)} · ${item.tab}/${item.concept}/${item.entry} ${theme.fg("dim", item.reason)}`,
171
+ width,
172
+ "…",
173
+ ),
174
+ ],
175
+ onResult: (result) =>
176
+ done(
177
+ result.kind === "primary" && result.items.length > 0
178
+ ? { kind: "approve", operations: [...result.items] }
179
+ : { kind: "rejected" },
180
+ ),
181
+ onSelectionChange: (selected) => {
182
+ this.selected = selected;
183
+ tui.requestRender();
184
+ },
185
+ });
186
+ this.list.setSelectedIds(items.map((item) => item.id));
187
+ this.selected = items;
188
+ this.panel = new ToolPanel(theme, {
189
+ title: "Maintain contexts",
190
+ secondary: "Review proposed operations",
191
+ body: {
192
+ render: (width) => [
193
+ ...this.list.render(width),
194
+ "",
195
+ ...operationPaths(this.current).map((path) =>
196
+ truncateToWidth(theme.fg("muted", `• ${path}`), width, "…"),
197
+ ),
198
+ ],
199
+ invalidate: () => this.list.invalidate(),
200
+ },
201
+ footer: {
202
+ kind: "hints",
203
+ hints: [
204
+ ...this.list.getKeyHints(),
205
+ rawHint("ctrl+b", "batch feedback"),
206
+ rawHint("ctrl+i", "item feedback"),
207
+ ],
208
+ },
209
+ });
210
+ }
211
+ handleInput(data: string): void {
212
+ if (!this.list.isFilterFocused()) {
213
+ const selectedIds = this.selected.map((item) => item.id);
214
+ if (matchesKey(data, Key.ctrl("b"))) {
215
+ this.done({ kind: "feedback", scope: "batch", selectedIds });
216
+ return;
217
+ }
218
+ if (matchesKey(data, Key.ctrl("i"))) {
219
+ const current = this.list.getCurrentItem();
220
+ if (current) this.done({ kind: "feedback", scope: "operation", operationId: current.id, selectedIds });
221
+ return;
222
+ }
223
+ }
224
+ this.list.handleInput(data);
225
+ this.current = this.list.getCurrentItem() ?? this.current;
226
+ }
227
+ render(width: number): string[] {
228
+ return this.panel.render(width);
229
+ }
230
+ invalidate(): void {
231
+ this.panel.invalidate();
232
+ }
233
+ }
234
+
235
+ function operationPaths(operation: ContextOperation): string[] {
236
+ return operation.kind === "replace-file" ? [`${operation.from} → ${operation.to}`] : operation.files;
237
+ }
@@ -4,12 +4,13 @@ Subagent delegates one focused task to an isolated child Pi session. It keeps th
4
4
 
5
5
  Agent definitions can override the parent model and thinking level. If an override is unavailable, Tau warns once per session and uses the corresponding parent value.
6
6
 
7
- Tau includes two read-only agents:
7
+ Tau includes three built-in agents:
8
8
 
9
9
  - `scout` explores local files and code with `read`, `grep`, `find`, and `ls`.
10
+ - `context-maintenance` reconciles reusable repository context entries and requests approval before updates.
10
11
  - `web-research` researches web and code sources with `websearch`, `codesearch`, and `webfetch`.
11
12
 
12
- Ask Tau to delegate a task, or let it call `subagent` with an agent name and task. Children use the parent's current working directory and inherit its model and thinking level unless their definition overrides either value. They do not receive the parent conversation. When a child must inspect another repository, put its exact absolute path in the delegated task.
13
+ Ask Tau to delegate a task, or let it call `subagent` with an agent name and task. Children use the parent's current working directory and inherit its model and thinking level unless their definition overrides either value. They do not receive the parent conversation. Tau loads only the extensions that own a child's declared tools, so unrelated extension hooks do not run in child sessions. When a child must inspect another repository, put its exact absolute path in the delegated task.
13
14
 
14
15
  ## Agent definitions
15
16
 
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: context-maintenance
3
+ description: Reconcile repository context definitions after meaningful scope changes, with user approval before every update
4
+ tools:
5
+ - read
6
+ - grep
7
+ - find
8
+ - ls
9
+ - context_list
10
+ - context_get
11
+ - context_check
12
+ - context_audit
13
+ - context_changes
14
+ - context_review
15
+ model: openai-codex/gpt-5.6-luna
16
+ thinking: high
17
+ ---
18
+
19
+ Maintain the repository context catalog. Research only the changed or requested scopes. Existing entries should stay small, reusable, and useful for future work. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.
20
+
21
+ Use context_changes when the task does not provide exact changed paths. Use context_list, context_get, context_check, and context_audit to understand the current catalog. Call context_review with concrete proposed operations. That tool owns user approval and applies only selected operations.
22
+
23
+ If context_review returns feedback, revise the affected proposal or batch and call context_review again. If it returns rejected, stop. Never claim an operation was applied unless context_review reports it applied. Finish with only a short applied/rejected summary.
@@ -126,6 +126,10 @@ async function loadScope(
126
126
  const reason = error instanceof Error ? error.message : "directory unavailable";
127
127
  return new Map([
128
128
  ["scout", [{ path: directory, name: "scout", reason: `packaged agents unavailable: ${reason}` }]],
129
+ [
130
+ "context-maintenance",
131
+ [{ path: directory, name: "context-maintenance", reason: `packaged agents unavailable: ${reason}` }],
132
+ ],
129
133
  [
130
134
  "web-research",
131
135
  [{ path: directory, name: "web-research", reason: `packaged agents unavailable: ${reason}` }],
@@ -3,7 +3,7 @@ import { Type } from "typebox";
3
3
  import { createToolRowStateStore } from "../../shared/tool-row-state.js";
4
4
  import { discoverAgents, type AgentDiscovery } from "./agents.ts";
5
5
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
6
- import { FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
6
+ import { extensionPathsForTools, FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
7
7
 
8
8
  const params = Type.Object(
9
9
  { agent: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
@@ -137,6 +137,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
137
137
  }
138
138
  const result = await runSubagent({
139
139
  definition,
140
+ extensionPaths: extensionPathsForTools(pi, definition.tools),
140
141
  task,
141
142
  ctx,
142
143
  thinkingLevel,
@@ -6,11 +6,14 @@ import {
6
6
  createAgentSession,
7
7
  DEFAULT_MAX_BYTES,
8
8
  DEFAULT_MAX_LINES,
9
+ DefaultResourceLoader,
9
10
  formatSize,
11
+ getAgentDir,
10
12
  SessionManager,
11
13
  truncateHead,
12
14
  type AgentSession,
13
15
  type AgentSessionEvent,
16
+ type ExtensionAPI,
14
17
  type ExtensionContext,
15
18
  } from "@earendil-works/pi-coding-agent";
16
19
  import type { AgentDefinition } from "./agents.ts";
@@ -18,6 +21,25 @@ import type { AgentDefinition } from "./agents.ts";
18
21
  const PREVIEW_LIMIT = 600;
19
22
  const VALUE_LIMIT = 180;
20
23
 
24
+ const CHILD_UI_BLOCKED_METHODS = new Set([
25
+ "setEditorComponent",
26
+ "setFooter",
27
+ "setStatus",
28
+ "setTitle",
29
+ "setWidget",
30
+ "setWorkingIndicator",
31
+ ]);
32
+
33
+ function childUiContext(ui: ExtensionContext["ui"]): ExtensionContext["ui"] {
34
+ return new Proxy(ui, {
35
+ get(target, property, receiver) {
36
+ if (typeof property === "string" && CHILD_UI_BLOCKED_METHODS.has(property)) return () => undefined;
37
+ const value = Reflect.get(target, property, receiver) as unknown;
38
+ return typeof value === "function" ? value.bind(target) : value;
39
+ },
40
+ });
41
+ }
42
+
21
43
  export interface SubagentAction {
22
44
  tool: string;
23
45
  summary: string;
@@ -58,6 +80,19 @@ export interface SubagentDetails {
58
80
  };
59
81
  }
60
82
 
83
+ export function extensionPathsForTools(pi: ExtensionAPI, tools: readonly string[]): string[] {
84
+ const selected = new Set(tools);
85
+ return [
86
+ ...new Set(
87
+ pi
88
+ .getAllTools()
89
+ .filter((tool) => selected.has(tool.name))
90
+ .map((tool) => tool.sourceInfo.path)
91
+ .filter((path) => path.length > 0 && !path.startsWith("<")),
92
+ ),
93
+ ].sort();
94
+ }
95
+
61
96
  export class FifoGate {
62
97
  private active = 0;
63
98
  private readonly waiters: Array<{
@@ -125,6 +160,7 @@ function assistants(session: AgentSession): AssistantMessage[] {
125
160
 
126
161
  export async function runSubagent(options: {
127
162
  definition: AgentDefinition;
163
+ extensionPaths: readonly string[];
128
164
  task: string;
129
165
  ctx: ExtensionContext;
130
166
  thinkingLevel: string;
@@ -132,7 +168,16 @@ export async function runSubagent(options: {
132
168
  onUpdate?: (details: SubagentDetails) => void | Promise<void>;
133
169
  onWarning?: (warning: string) => void;
134
170
  }): Promise<{ content: string; details: SubagentDetails }> {
135
- const { definition, task, ctx, thinkingLevel: parentThinkingLevel, signal, onUpdate, onWarning } = options;
171
+ const {
172
+ definition,
173
+ extensionPaths,
174
+ task,
175
+ ctx,
176
+ thinkingLevel: parentThinkingLevel,
177
+ signal,
178
+ onUpdate,
179
+ onWarning,
180
+ } = options;
136
181
  const started = Date.now();
137
182
  let model = ctx.model;
138
183
  let thinkingLevel = parentThinkingLevel;
@@ -193,6 +238,13 @@ export async function runSubagent(options: {
193
238
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
194
239
  if (!auth.ok) throw new Error(`Agent ${definition.name} startup failed: ${auth.error}`);
195
240
  if (signal.aborted) throw new Error(`Agent ${definition.name} startup aborted`);
241
+ const resourceLoader = new DefaultResourceLoader({
242
+ cwd: ctx.cwd,
243
+ agentDir: getAgentDir(),
244
+ noExtensions: true,
245
+ additionalExtensionPaths: [...extensionPaths],
246
+ });
247
+ await resourceLoader.reload();
196
248
  const created = await createAgentSession({
197
249
  cwd: ctx.cwd,
198
250
  model,
@@ -200,10 +252,13 @@ export async function runSubagent(options: {
200
252
  modelRegistry: ctx.modelRegistry,
201
253
  tools: definition.tools,
202
254
  excludeTools: ["subagent"],
255
+ resourceLoader,
203
256
  sessionManager: SessionManager.inMemory(ctx.cwd),
204
257
  });
205
258
  session = created.session;
206
- await session.bindExtensions({ mode: "print" });
259
+ await session.bindExtensions(
260
+ ctx.mode === "tui" && ctx.hasUI ? { mode: "tui", uiContext: childUiContext(ctx.ui) } : { mode: "print" },
261
+ );
207
262
  const active = session.getActiveToolNames().sort();
208
263
  const expected = [...definition.tools].sort();
209
264
  if (active.join("\0") !== expected.join("\0") || active.includes("subagent")) {
@@ -26,6 +26,10 @@ Adds `/clear-screen` to clear terminal output without changing the session.
26
26
 
27
27
  Adds `/commit` for semantic commit grouping, review, and committing selected repository changes.
28
28
 
29
+ ## context
30
+
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-manage <idea>` to run interactive, approval-based catalog maintenance. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
+
29
33
  ## explore
30
34
 
31
35
  Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. `read` includes focused line ranges; `grep`, `find`, and `ls` keep discovery output compact so the agent spends fewer tokens rereading directory and search results.
@@ -80,7 +84,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
80
84
 
81
85
  ## subagent
82
86
 
83
- Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout` and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
87
+ Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout`, `context-maintenance`, and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
84
88
 
85
89
  ## tau-help
86
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,9 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@shanepadgett/tau-tui": "0.4.0",
32
- "@toon-format/toon": "2.3.0"
31
+ "@shanepadgett/tau-tui": "0.5.0",
32
+ "@toon-format/toon": "2.3.0",
33
+ "smol-toml": "1.4.2"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "@earendil-works/pi-ai": "*",
@@ -3,7 +3,7 @@ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
  export const INJECTED_CONTEXT_TYPE = "tau.injected-context";
4
4
 
5
5
  export interface InjectedContextDetails {
6
- source: "review" | "tau-context";
6
+ source: "review" | "context";
7
7
  title?: string;
8
8
  }
9
9
 
@@ -61,7 +61,7 @@ export function getBranchInjectedContexts(entries: readonly SessionEntry[]): Inj
61
61
  function readDetails(value: unknown): InjectedContextDetails | undefined {
62
62
  if (!value || typeof value !== "object") return undefined;
63
63
  const record = value as Record<string, unknown>;
64
- if (record.source !== "review" && record.source !== "tau-context") return undefined;
64
+ if (record.source !== "review" && record.source !== "context") return undefined;
65
65
  return {
66
66
  source: record.source,
67
67
  ...(typeof record.title === "string" ? { title: record.title } : {}),