@shanepadgett/tau-agent 0.3.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 +3 -1
- package/extensions/context/README.md +20 -0
- package/extensions/context/definitions.ts +258 -0
- package/extensions/context/index.ts +476 -0
- package/extensions/context/panel.ts +237 -0
- package/extensions/publish/index.ts +8 -4
- package/extensions/publish/publish-ui.ts +21 -2
- package/extensions/subagent/README.md +3 -2
- package/extensions/subagent/agents/context-maintenance.md +23 -0
- package/extensions/subagent/agents.ts +4 -0
- package/extensions/subagent/index.ts +2 -1
- package/extensions/subagent/run.ts +57 -2
- package/extensions/tau-help/help.md +5 -1
- package/package.json +4 -3
- package/shared/injected-context.ts +2 -2
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
|
+
}
|
|
@@ -109,21 +109,25 @@ async function completePublish(
|
|
|
109
109
|
progress: PublishProgress | undefined = undefined,
|
|
110
110
|
): Promise<void> {
|
|
111
111
|
if (tag) {
|
|
112
|
-
progress?.update("
|
|
112
|
+
progress?.update("write package versions");
|
|
113
113
|
await writeReleaseVersion(root, manifests, version);
|
|
114
114
|
}
|
|
115
|
-
progress?.update("
|
|
115
|
+
progress?.update("npm pack --dry-run @shanepadgett/tau-tui");
|
|
116
116
|
await run(pi, ctx, "npm", ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-tui"], root);
|
|
117
|
+
progress?.update("npm pack --dry-run @shanepadgett/tau-agent");
|
|
117
118
|
await run(pi, ctx, "npm", ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-agent"], root);
|
|
118
119
|
|
|
119
120
|
if (tag) {
|
|
120
|
-
progress?.update("
|
|
121
|
+
progress?.update("git add release files");
|
|
121
122
|
await git.run(["add", ...PACKAGE_PATHS, LOCKFILE_PATH], { cwd: root });
|
|
123
|
+
progress?.update(`git commit chore(release): ${releaseTag}`);
|
|
122
124
|
await git.run(["commit", "-m", `chore(release): ${releaseTag}`], { cwd: root });
|
|
123
125
|
}
|
|
124
|
-
progress?.update(`
|
|
126
|
+
progress?.update(`git tag ${releaseTag}`);
|
|
125
127
|
await git.run(["tag", releaseTag], { cwd: root });
|
|
128
|
+
progress?.update("git push origin HEAD");
|
|
126
129
|
await git.run(["push", "origin", "HEAD"], { cwd: root, timeout: 120_000 });
|
|
130
|
+
progress?.update(`git push origin ${releaseTag}`);
|
|
127
131
|
await git.run(["push", "origin", releaseTag], { cwd: root, timeout: 120_000 });
|
|
128
132
|
progress?.update("Waiting for GitHub Actions");
|
|
129
133
|
await monitorWorkflow(pi, ctx, root, await git.run(["rev-parse", "HEAD"], { cwd: root }), releaseTag, progress);
|
|
@@ -2,6 +2,19 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { type Component, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { ToolPanel } from "@shanepadgett/tau-tui";
|
|
4
4
|
|
|
5
|
+
const RELEASE_STEPS = [
|
|
6
|
+
"write package versions",
|
|
7
|
+
"npm pack --dry-run @shanepadgett/tau-tui",
|
|
8
|
+
"npm pack --dry-run @shanepadgett/tau-agent",
|
|
9
|
+
"git add release files",
|
|
10
|
+
"git commit",
|
|
11
|
+
"git tag",
|
|
12
|
+
"git push origin HEAD",
|
|
13
|
+
"git push origin v",
|
|
14
|
+
"Waiting for GitHub Actions",
|
|
15
|
+
"GitHub Actions: publishing",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
5
18
|
export interface PublishProgress {
|
|
6
19
|
update(status: string, detail?: string): void;
|
|
7
20
|
}
|
|
@@ -13,6 +26,7 @@ export class PublishActivityPanel implements Component, PublishProgress {
|
|
|
13
26
|
private readonly panel: ToolPanel;
|
|
14
27
|
private status = "Preparing release";
|
|
15
28
|
private detail: string | undefined;
|
|
29
|
+
private readonly completed: string[] = ["Release confirmed"];
|
|
16
30
|
|
|
17
31
|
constructor(tui: TUI, theme: Theme, tag: string) {
|
|
18
32
|
this.tui = tui;
|
|
@@ -22,12 +36,13 @@ export class PublishActivityPanel implements Component, PublishProgress {
|
|
|
22
36
|
secondary: "Editor is unavailable until this release finishes.",
|
|
23
37
|
header: [theme.fg("muted", "GitHub Actions publishes through npm trusted publishing.")],
|
|
24
38
|
body: this.body,
|
|
25
|
-
footer: { kind: "
|
|
39
|
+
footer: { kind: "hints", hints: [] },
|
|
26
40
|
});
|
|
27
41
|
this.renderBody();
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
update(status: string, detail: string | undefined = undefined): void {
|
|
45
|
+
if (this.status !== "Preparing release" && this.status !== status) this.completed.push(this.status);
|
|
31
46
|
this.status = status;
|
|
32
47
|
this.detail = detail;
|
|
33
48
|
this.renderBody();
|
|
@@ -43,11 +58,15 @@ export class PublishActivityPanel implements Component, PublishProgress {
|
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
private renderBody(): void {
|
|
61
|
+
const upcoming = RELEASE_STEPS.filter(
|
|
62
|
+
(step) => !this.completed.some((completed) => completed.startsWith(step)) && step !== this.status,
|
|
63
|
+
);
|
|
46
64
|
this.body.setText(
|
|
47
65
|
[
|
|
48
|
-
this.theme.fg("success",
|
|
66
|
+
...this.completed.map((step) => this.theme.fg("success", `✓ ${step}`)),
|
|
49
67
|
this.theme.fg("accent", `… ${this.status}`),
|
|
50
68
|
...(this.detail ? [this.theme.fg("muted", this.detail)] : []),
|
|
69
|
+
...upcoming.map((step) => this.theme.fg("muted", `· ${step}`)),
|
|
51
70
|
].join("\n"),
|
|
52
71
|
);
|
|
53
72
|
}
|
|
@@ -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
|
|
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 {
|
|
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(
|
|
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.
|
|
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.
|
|
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" | "
|
|
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 !== "
|
|
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 } : {}),
|