@shanepadgett/tau-agent 0.12.0 → 0.14.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
@@ -2,6 +2,15 @@
2
2
 
3
3
  Tau's `subagent` tool delegates one focused task to an isolated child Pi session. You can add your own agent definitions without writing TypeScript.
4
4
 
5
+ Each fresh call returns a thread ID. Continue that thread when feedback or follow-up work depends on the child's prior reads and reasoning:
6
+
7
+ ```text
8
+ subagent({ agent: "scout", task: "Trace configuration loading" })
9
+ subagent({ thread: "thread-1", task: "Now check whether this proposed fix covers every caller" })
10
+ ```
11
+
12
+ Retained threads keep their child conversation and tool results for the current parent session. Start fresh for unrelated work or when earlier context is stale or oversized. Tau keeps up to 16 threads and evicts the least recently used idle thread when needed.
13
+
5
14
  ## Where definitions live
6
15
 
7
16
  | Scope | Path | Use when |
@@ -46,8 +55,10 @@ Named tools and configured models must exist in the normally loaded child Pi env
46
55
 
47
56
  - Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
48
57
  - Children do not receive the parent conversation.
58
+ - Follow-up calls reuse the retained child's conversation, model, thinking level, tools, and cwd.
49
59
  - Children load only the extensions that own their declared tools. Unrelated extension hooks do not run in child sessions.
50
60
  - At most four children run at once; extra calls wait in order.
61
+ - Calls to the same retained thread run one at a time.
51
62
  - Returned text is capped (50 KB / 2,000 lines); full truncated output is saved to a private temp file.
52
63
 
53
64
  ## Built-ins
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- Use `/context` to select entries and inject their files through Tau autoread. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
5
+ Use `/context` to select entries. Entry `files` are injected through Tau autoread. Entry `anchors` supply lazy navigation paths that the agent can grep or read in ranges when needed. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates both file classes as context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
6
6
 
7
7
  Validation is disabled by default. Enable it globally or per project in Tau settings:
8
8
 
@@ -30,6 +30,7 @@ files = ["src/player/input.ts"]
30
30
  [movement]
31
31
  description = "Player locomotion and collision"
32
32
  files = ["src/player/movement.ts"]
33
+ anchors = ["src/runtime/fetch-handler.ts"]
33
34
  ```
34
35
 
35
- For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries.
36
+ For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries. When `movement` is selected, Tau autoreads `src/player/movement.ts` and lists `src/runtime/fetch-handler.ts` as an unloaded anchor. If one selected entry marks a path as a file and another marks it as an anchor, autoread wins.
@@ -13,6 +13,7 @@ export interface ContextEntry {
13
13
  name: string;
14
14
  description: string;
15
15
  files: string[];
16
+ anchors: string[];
16
17
  path: string;
17
18
  }
18
19
 
@@ -36,6 +37,7 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
36
37
  "uv.lock",
37
38
  "yarn.lock",
38
39
  ]);
40
+ const CONTEXT_ENTRY_FIELDS = new Set(["description", "files", "anchors"]);
39
41
 
40
42
  export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
41
43
  return (
@@ -97,6 +99,10 @@ function sortedUnique(values: readonly string[]): string[] {
97
99
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
98
100
  }
99
101
 
102
+ export function contextEntryPaths(entry: Pick<ContextEntry, "files" | "anchors">): string[] {
103
+ return sortedUnique([...entry.files, ...entry.anchors]);
104
+ }
105
+
100
106
  export async function requireFiles(root: string, inputs: readonly string[]): Promise<string[]> {
101
107
  const files = sortedUnique(inputs.map((input) => normalizeProjectPath(root, input)));
102
108
  for (const file of files) {
@@ -132,15 +138,24 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
132
138
  if (!value || typeof value !== "object" || Array.isArray(value))
133
139
  throw new Error(`Invalid context entry: ${path} [${name}]`);
134
140
  const record = value as Record<string, unknown>;
141
+ const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
142
+ if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
143
+ const anchors = record.anchors ?? [];
135
144
  if (
136
145
  typeof record.description !== "string" ||
137
146
  !record.description.trim() ||
138
147
  !Array.isArray(record.files) ||
139
- record.files.length === 0 ||
140
- record.files.some((item) => typeof item !== "string")
148
+ record.files.some((item) => typeof item !== "string") ||
149
+ !Array.isArray(anchors) ||
150
+ anchors.some((item) => typeof item !== "string") ||
151
+ (record.files.length === 0 && anchors.length === 0)
141
152
  )
142
153
  throw new Error(`Invalid context entry: ${path} [${name}]`);
143
154
  const entry = validSlug(name, "Context entry");
155
+ const entryFiles = sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item)));
156
+ const entryAnchors = sortedUnique((anchors as string[]).map((item) => normalizeProjectPath(root, item)));
157
+ const overlap = entryFiles.find((item) => entryAnchors.includes(item));
158
+ if (overlap) throw new Error(`Context path cannot be both file and anchor: ${path} [${name}] ${overlap}`);
144
159
  result.push({
145
160
  id: `${tab}/${concept}/${entry}`,
146
161
  tab,
@@ -149,7 +164,8 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
149
164
  conceptDescription,
150
165
  name: entry,
151
166
  description: record.description.trim(),
152
- files: sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item))),
167
+ files: entryFiles,
168
+ anchors: entryAnchors,
153
169
  path,
154
170
  });
155
171
  }
@@ -53,19 +53,35 @@ export default function contextExtension(pi: ExtensionAPI): void {
53
53
  );
54
54
  if (!selected?.length) return;
55
55
  const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
56
+ const fileSet = new Set(files);
57
+ const anchors = [...new Set(selected.flatMap((entry) => entry.anchors))]
58
+ .filter((path) => !fileSet.has(path))
59
+ .sort();
56
60
  pi.sendMessage(
57
61
  createInjectedContext(
58
- "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.",
62
+ [
63
+ "Selected repository context:",
64
+ ...selected.map((entry) => `- ${entry.id}: ${entry.description}`),
65
+ "",
66
+ "Eager snapshots supplied through autoread:",
67
+ ...(files.length ? files.map((path) => `- ${path}`) : ["(none)"]),
68
+ "",
69
+ "Lazy navigation anchors whose contents have not been loaded:",
70
+ ...(anchors.length ? anchors.map((path) => `- ${path}`) : ["(none)"]),
71
+ "",
72
+ "Treat eager snapshots as authoritative current project context. Do not reread them or search for coverage around them. Inspect only the anchors needed for the request, using grep or bounded reads. Explore elsewhere only when the request or concrete evidence requires missing information.",
73
+ ].join("\n"),
59
74
  { source: "context", title: "Project context" },
60
75
  ),
61
76
  );
62
- emitTauEvent(pi, "tau:autoread.requested", {
63
- source: "context",
64
- title: "Project context",
65
- cwd: root,
66
- batchId: randomUUID(),
67
- files: files.map((path) => ({ path })),
68
- });
77
+ if (files.length)
78
+ emitTauEvent(pi, "tau:autoread.requested", {
79
+ source: "context",
80
+ title: "Project context",
81
+ cwd: root,
82
+ batchId: randomUUID(),
83
+ files: files.map((path) => ({ path })),
84
+ });
69
85
  },
70
86
  });
71
87
 
@@ -73,14 +73,15 @@ export class ContextPanel implements Component {
73
73
  const list = this.activeList();
74
74
  if (!list?.isFilterFocused()) {
75
75
  const keys = getKeybindings();
76
- const pageSize = this.filePageSize();
77
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("up"))) {
76
+ const paths = this.currentPaths();
77
+ const pageSize = this.pathPageSize();
78
+ if (paths.length > pageSize && matchesKey(data, Key.alt("up"))) {
78
79
  this.fileOffset = Math.max(0, this.fileOffset - pageSize);
79
80
  this.sync();
80
81
  return;
81
82
  }
82
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("down"))) {
83
- this.fileOffset = Math.min(this.current.files.length - pageSize, this.fileOffset + pageSize);
83
+ if (paths.length > pageSize && matchesKey(data, Key.alt("down"))) {
84
+ this.fileOffset = Math.min(paths.length - pageSize, this.fileOffset + pageSize);
84
85
  this.sync();
85
86
  return;
86
87
  }
@@ -122,35 +123,51 @@ export class ContextPanel implements Component {
122
123
  return {
123
124
  render: (width) => {
124
125
  const tabs = this.tabs.render(width);
125
- const pageSize = this.filePageSize(tabs.length);
126
- const maxOffset = Math.max(0, this.current.files.length - pageSize);
126
+ const paths = this.currentPaths();
127
+ const pageSize = this.pathPageSize(tabs.length);
128
+ const maxOffset = Math.max(0, paths.length - pageSize);
127
129
  this.fileOffset = Math.min(this.fileOffset, maxOffset);
128
- const files = this.current.files.slice(this.fileOffset, this.fileOffset + pageSize);
130
+ const visible = paths.slice(this.fileOffset, this.fileOffset + pageSize);
129
131
  const range =
130
- this.current.files.length > pageSize
132
+ paths.length > pageSize
131
133
  ? this.theme.fg(
132
134
  "dim",
133
- `${this.fileOffset + 1}-${this.fileOffset + files.length} of ${this.current.files.length} files`,
135
+ `${this.fileOffset + 1}-${this.fileOffset + visible.length} of ${paths.length} paths`,
134
136
  )
135
137
  : undefined;
136
138
  return [
137
139
  ...tabs,
138
140
  "",
139
141
  ...(range ? [truncateToWidth(range, width, "…")] : []),
140
- ...files.map((file) => truncateToWidth(this.theme.fg("muted", `• ${file}`), width, "…")),
142
+ ...visible.map(({ kind, path }) =>
143
+ truncateToWidth(
144
+ this.theme.fg(
145
+ kind === "read" ? "muted" : "dim",
146
+ `${kind === "read" ? "read" : "anchor"} • ${path}`,
147
+ ),
148
+ width,
149
+ "…",
150
+ ),
151
+ ),
141
152
  ];
142
153
  },
143
154
  invalidate: () => this.tabs.invalidate(),
144
155
  };
145
156
  }
146
- private filePageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
157
+ private currentPaths(): Array<{ kind: "read" | "anchor"; path: string }> {
158
+ return [
159
+ ...this.current.files.map((path) => ({ kind: "read" as const, path })),
160
+ ...this.current.anchors.map((path) => ({ kind: "anchor" as const, path })),
161
+ ];
162
+ }
163
+ private pathPageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
147
164
  const overlayHeight = Math.floor(this.tui.terminal.rows * 0.8);
148
165
  const available = Math.max(1, overlayHeight - tabLines - 7);
149
166
  const pageSize = Math.min(8, available);
150
- return this.current.files.length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
167
+ return this.currentPaths().length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
151
168
  }
152
169
  private secondary(): string {
153
- return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected`;
170
+ return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.files.length} read · ${this.current.anchors.length} anchors`;
154
171
  }
155
172
  private hints() {
156
173
  const list = this.activeList();
@@ -158,7 +175,7 @@ export class ContextPanel implements Component {
158
175
  ? list.getKeyHints()
159
176
  : [
160
177
  ...this.tabs.getKeyHints(),
161
- ...(this.current.files.length > this.filePageSize() ? [rawHint("option+↑/↓", "scroll files")] : []),
178
+ ...(this.currentPaths().length > this.pathPageSize() ? [rawHint("option+↑/↓", "scroll paths")] : []),
162
179
  rawHint("ctrl+c", "clear all"),
163
180
  bindingHint("tui.select.confirm", "inject"),
164
181
  bindingHint("tui.select.cancel", "cancel"),
@@ -10,6 +10,7 @@ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
10
10
  import { truncAt } from "../../shared/text.ts";
11
11
  import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
12
12
  import {
13
+ contextEntryPaths,
13
14
  isContextEligiblePath,
14
15
  isSensitiveContextPath,
15
16
  loadContextEntries,
@@ -216,13 +217,14 @@ async function collectSyncEvidence(
216
217
  for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
217
218
  const missingPaths = new Set<string>();
218
219
  for (const entry of entries)
219
- for (const path of entry.files)
220
+ for (const path of contextEntryPaths(entry))
220
221
  if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
221
222
  const affectedIds = new Set<string>();
222
223
  for (const entry of entries) {
224
+ const paths = contextEntryPaths(entry);
223
225
  if (
224
- entry.files.some((path) => missingPaths.has(path)) ||
225
- entry.files.some(
226
+ paths.some((path) => missingPaths.has(path)) ||
227
+ paths.some(
226
228
  (path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
227
229
  )
228
230
  )
@@ -231,7 +233,7 @@ async function collectSyncEvidence(
231
233
  const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
232
234
  const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
233
235
  const siblingFiles = siblingEntries
234
- .flatMap((entry) => entry.files)
236
+ .flatMap((entry) => contextEntryPaths(entry))
235
237
  .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
236
238
  const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
237
239
  const structuralPreviews = new Map<string, string>();
@@ -327,9 +329,13 @@ async function collectDirtyFiles(
327
329
  sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
328
330
  ...file,
329
331
  id: offset + inner + 1,
330
- memberships: entries.filter((entry) => entry.files.includes(file.path)).map((entry) => entry.id),
332
+ memberships: entries
333
+ .filter((entry) => contextEntryPaths(entry).includes(file.path))
334
+ .map((entry) => entry.id),
331
335
  oldMemberships: file.oldPath
332
- ? entries.filter((entry) => entry.files.includes(file.oldPath ?? "")).map((entry) => entry.id)
336
+ ? entries
337
+ .filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
338
+ .map((entry) => entry.id)
333
339
  : [],
334
340
  evidence: await dirtyEvidence(git, root, file),
335
341
  })),
@@ -431,7 +437,9 @@ async function isFile(path: string): Promise<boolean> {
431
437
 
432
438
  function buildContextSyncPrompt(evidence: SyncEvidence): string {
433
439
  const stale = evidence.entries.flatMap((entry) =>
434
- entry.files.filter((path) => evidence.missingPaths.has(path)).map((path) => `${entry.id}: ${path}`),
440
+ contextEntryPaths(entry)
441
+ .filter((path) => evidence.missingPaths.has(path))
442
+ .map((path) => `${entry.id}: ${path}`),
435
443
  );
436
444
  const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
437
445
  const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
@@ -441,6 +449,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
441
449
  "Call submit_context_sync exactly once and produce no prose response.",
442
450
  "Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
443
451
  "Context entries are reusable work scopes, not inventories of every touched file. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.",
452
+ "The files field in your changes is desired membership, including eager files and lazy anchors. Paths already classified anywhere in the catalog preserve their loading class; new paths become eager files. Do not change loading policy.",
444
453
  "Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
445
454
  "Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
446
455
  "Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
@@ -457,8 +466,9 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
457
466
  conceptName: entry.conceptName,
458
467
  conceptDescription: entry.conceptDescription,
459
468
  description: entry.description,
460
- files: entry.files,
461
- missingFiles: entry.files.filter((path) => evidence.missingPaths.has(path)),
469
+ eagerFiles: entry.files,
470
+ anchors: entry.anchors,
471
+ missingFiles: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
462
472
  })),
463
473
  ),
464
474
  "Changed files with no membership:",
@@ -540,7 +550,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
540
550
  throw new Error(`Entry is unrelated: ${id}`);
541
551
  if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
542
552
  throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
543
- if (description === existing.description && files.join("\0") === existing.files.join("\0"))
553
+ if (description === existing.description && files.join("\0") === contextEntryPaths(existing).join("\0"))
544
554
  throw new Error(`Set-entry is identical: ${id}`);
545
555
  } else {
546
556
  const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
@@ -560,7 +570,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
560
570
  }
561
571
  changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
562
572
  }
563
- const final = new Map(evidence.entries.map((entry) => [entry.id, [...entry.files]]));
573
+ const final = new Map(evidence.entries.map((entry) => [entry.id, contextEntryPaths(entry)]));
564
574
  for (const change of changes) {
565
575
  const id = `${change.tab}/${change.concept}/${change.entry}`;
566
576
  if (change.action === "delete-entry") final.delete(id);
@@ -571,7 +581,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
571
581
  )) {
572
582
  const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
573
583
  if (!old) continue;
574
- for (const path of old.files)
584
+ for (const path of contextEntryPaths(old))
575
585
  if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
576
586
  throw new Error(`Deleting entry would orphan surviving file: ${path}`);
577
587
  }
@@ -640,6 +650,8 @@ export async function applyContextSyncPlan(
640
650
  ): Promise<ContextSyncDetails> {
641
651
  for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
642
652
  const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
653
+ const eagerPaths = new Set(entries.flatMap((entry) => entry.files));
654
+ const anchorPaths = new Set(entries.flatMap((entry) => entry.anchors).filter((path) => !eagerPaths.has(path)));
643
655
  const outputs = new Map<string, string | undefined>();
644
656
  for (const key of concepts) {
645
657
  const [tab, concept] = key.split("/");
@@ -653,7 +665,12 @@ export async function applyContextSyncPlan(
653
665
  else {
654
666
  if (raw.name === undefined) raw.name = change.conceptName;
655
667
  if (raw.description === undefined) raw.description = change.conceptDescription;
656
- raw[change.entry] = { description: change.description, files: change.files };
668
+ const anchors = change.files.filter((path) => anchorPaths.has(path));
669
+ raw[change.entry] = {
670
+ description: change.description,
671
+ files: change.files.filter((path) => !anchors.includes(path)),
672
+ ...(anchors.length ? { anchors } : {}),
673
+ };
657
674
  }
658
675
  }
659
676
  outputs.set(
@@ -1,7 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { GitRunner } from "../../shared/git.ts";
4
- import { isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
4
+ import { contextEntryPaths, isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
5
5
 
6
6
  export interface ContextValidationResult {
7
7
  stale: Array<{ path: string; ids: string[] }>;
@@ -16,7 +16,7 @@ export async function validateContextCatalog(
16
16
  const entries = await loadContextEntries(root);
17
17
  const memberships = new Map<string, string[]>();
18
18
  for (const entry of entries)
19
- for (const file of entry.files) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
19
+ for (const file of contextEntryPaths(entry)) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
20
20
 
21
21
  const stale: Array<{ path: string; ids: string[] }> = [];
22
22
  for (const [path, ids] of memberships) {
@@ -12,6 +12,8 @@ Tau includes three built-in agents:
12
12
 
13
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.
14
14
 
15
+ Fresh calls return a thread ID. Tau can send feedback or follow-up work to that thread, preserving the child's conversation, prior reads, and tool results. It starts a fresh thread for unrelated work or when retained context is stale or oversized. Threads live for the current parent session. Tau retains up to 16 and evicts the least recently used idle thread when needed. Calls to one thread run sequentially.
16
+
15
17
  ## Agent definitions
16
18
 
17
19
  Add Markdown definitions at `~/.pi/agent/tau/agents/*.md` or, in a trusted project, the nearest `.pi/tau/agents/*.md`. Project definitions override user definitions, which override built-ins. Duplicate names in one scope are invalid.
@@ -1,20 +1,66 @@
1
1
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { createToolRowStateStore } from "../../shared/tool-row-state.js";
4
- import { discoverAgents, type AgentDiscovery } from "./agents.ts";
4
+ import { discoverAgents, type AgentDefinition, type AgentDiscovery } from "./agents.ts";
5
5
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
6
- import { extensionPathsForTools, FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
6
+ import {
7
+ createSubagentThread,
8
+ disposeSubagentThread,
9
+ extensionPathsForTools,
10
+ FifoGate,
11
+ runSubagentTurn,
12
+ type SubagentDetails,
13
+ type SubagentThread,
14
+ } from "./run.ts";
7
15
 
8
- const params = Type.Object(
9
- { agent: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
10
- { additionalProperties: false },
11
- );
16
+ const params = Type.Union([
17
+ Type.Object(
18
+ { agent: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
19
+ { additionalProperties: false },
20
+ ),
21
+ Type.Object(
22
+ { thread: Type.String({ minLength: 1 }), task: Type.String({ minLength: 1 }) },
23
+ { additionalProperties: false },
24
+ ),
25
+ ]);
26
+
27
+ const MAX_RETAINED_THREADS = 16;
28
+
29
+ function emptyDetails(
30
+ agent: string,
31
+ task: string,
32
+ status: "waiting" | "failed" | "aborted",
33
+ phase: "discovery" | "queue" | "startup",
34
+ model: string,
35
+ thinkingLevel: string,
36
+ threadId: string | undefined,
37
+ error: string | undefined,
38
+ ): SubagentDetails {
39
+ return {
40
+ agent,
41
+ ...(threadId === undefined ? {} : { threadId }),
42
+ status,
43
+ phase,
44
+ task,
45
+ model,
46
+ thinkingLevel,
47
+ toolCalls: 0,
48
+ actions: [],
49
+ omittedActions: 0,
50
+ omittedErrors: 0,
51
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
52
+ durationMs: 0,
53
+ ...(error === undefined ? {} : { error }),
54
+ };
55
+ }
12
56
 
13
57
  export default function subagentExtension(pi: ExtensionAPI): void {
14
58
  const gate = new FifoGate(4);
15
59
  const controllers = new Set<AbortController>();
60
+ const threads = new Map<string, SubagentThread>();
16
61
  const fingerprints = new Map<string, string>();
17
62
  const runtimeWarnings = new Set<string>();
63
+ let nextThreadId = 1;
18
64
  const rowState = createToolRowStateStore(pi, "subagent.tool-row-state");
19
65
  const warn = (discovery: AgentDiscovery, ctx: ExtensionContext) => {
20
66
  const current = new Set<string>();
@@ -33,6 +79,11 @@ export default function subagentExtension(pi: ExtensionAPI): void {
33
79
  }
34
80
  for (const path of fingerprints.keys()) if (!current.has(path)) fingerprints.delete(path);
35
81
  };
82
+ const disposeThreads = async () => {
83
+ const retained = [...threads.values()];
84
+ threads.clear();
85
+ await Promise.all(retained.map((thread) => disposeSubagentThread(thread)));
86
+ };
36
87
  pi.on("before_agent_start", async (event, ctx) => {
37
88
  if (!pi.getActiveTools().includes("subagent")) return undefined;
38
89
  const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
@@ -40,7 +91,23 @@ export default function subagentExtension(pi: ExtensionAPI): void {
40
91
  const lines = [...discovery.agents.values()]
41
92
  .sort((a, b) => a.name.localeCompare(b.name))
42
93
  .map((agent) => `- ${agent.name}: ${agent.description}`);
43
- const prompt = `## Subagents\nUse \`subagent\` when an available agent matches a focused part of the task.\n\nAvailable agents for this turn:\n${lines.join("\n")}\n\nDelegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory.`;
94
+ const activeThreads = [...threads.values()]
95
+ .filter((thread) => thread.cwd === ctx.cwd)
96
+ .sort((a, b) => a.id.localeCompare(b.id))
97
+ .map((thread) => {
98
+ const task = thread.initialTask.replace(/\s+/g, " ").trim();
99
+ return `- ${thread.id} (${thread.definition.name}): ${task.length <= 160 ? task : `${task.slice(0, 159)}…`}`;
100
+ });
101
+ const threadSection = activeThreads.length ? `\n\nActive reusable threads:\n${activeThreads.join("\n")}` : "";
102
+ const prompt = `## Subagents
103
+ Use \`subagent\` when an available agent matches a focused part of the task.
104
+
105
+ Available agents for this turn:
106
+ ${lines.join("\n")}
107
+
108
+ Start a fresh thread with \`agent\` and \`task\`. Continue an existing thread with \`thread\` and \`task\`. Reuse a thread when feedback or follow-up work depends on its prior reads and reasoning. Start fresh for unrelated work or when its context is stale or oversized.${threadSection}
109
+
110
+ Delegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory.`;
44
111
  return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
45
112
  });
46
113
  pi.registerTool(
@@ -48,116 +115,142 @@ export default function subagentExtension(pi: ExtensionAPI): void {
48
115
  name: "subagent",
49
116
  label: "Subagent",
50
117
  description:
51
- "Delegate one focused task to a named isolated child agent. Output is limited to 50KB or 2,000 lines.",
52
- promptSnippet: "Delegate one focused task to a named isolated child agent",
118
+ "Start a focused isolated child agent or continue a retained child thread. Output is limited to 50KB or 2,000 lines.",
119
+ promptSnippet: "Start a focused child agent or continue a retained child thread",
53
120
  parameters: params,
54
121
  executionMode: "parallel",
55
122
  async execute(_id, raw, signal, onUpdate, ctx) {
56
- const agent = raw.agent.trim();
57
123
  const task = raw.task.trim();
58
- const thinkingLevel = pi.getThinkingLevel();
59
- if (!agent || !task)
60
- return {
61
- content: [{ type: "text", text: "Subagent input requires non-empty agent and task" }],
62
- details: {
63
- agent,
64
- task,
65
- status: "failed",
66
- phase: "queue",
67
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
68
- thinkingLevel,
69
- toolCalls: 0,
70
- actions: [],
71
- omittedActions: 0,
72
- omittedErrors: 0,
73
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
74
- durationMs: 0,
75
- error: "Invalid input",
76
- },
77
- };
78
- const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
79
- warn(discovery, ctx);
80
- const definition = discovery.agents.get(agent);
81
- const invalid = discovery.invalid.get(agent);
82
- if (!definition) {
83
- const reason = invalid?.map((item) => item.reason).join("; ") ?? "unknown agent";
84
- const names = [...discovery.agents.keys()].sort().join(", ") || "none";
85
- const error = `Agent ${agent} discovery failed: ${reason}. Runnable agents: ${names}`;
124
+ const continuing = "thread" in raw;
125
+ const parentModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable";
126
+ const parentThinking = pi.getThinkingLevel();
127
+ let thread: SubagentThread | undefined;
128
+ let definition: AgentDefinition | undefined;
129
+ let agent = continuing ? raw.thread.trim() : raw.agent.trim();
130
+ let threadId = continuing ? raw.thread.trim() : undefined;
131
+ if (!task || !agent) {
132
+ const error = continuing
133
+ ? "Subagent continuation requires non-empty thread and task"
134
+ : "Subagent input requires non-empty agent and task";
86
135
  return {
87
136
  content: [{ type: "text", text: error }],
88
- details: {
89
- agent,
90
- task,
91
- status: "failed",
92
- phase: "discovery",
93
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
94
- thinkingLevel,
95
- toolCalls: 0,
96
- actions: [],
97
- omittedActions: 0,
98
- omittedErrors: 0,
99
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
100
- durationMs: 0,
101
- error,
102
- },
137
+ details: emptyDetails(agent, task, "failed", "queue", parentModel, parentThinking, threadId, error),
103
138
  };
104
139
  }
140
+ if (continuing) {
141
+ thread = threads.get(agent);
142
+ if (!thread || thread.cwd !== ctx.cwd) {
143
+ const names =
144
+ [...threads.values()]
145
+ .filter((item) => item.cwd === ctx.cwd)
146
+ .map((item) => item.id)
147
+ .sort()
148
+ .join(", ") || "none";
149
+ const error = `Subagent thread ${agent} is unavailable. Active threads: ${names}`;
150
+ return {
151
+ content: [{ type: "text", text: error }],
152
+ details: emptyDetails(
153
+ agent,
154
+ task,
155
+ "failed",
156
+ "discovery",
157
+ parentModel,
158
+ parentThinking,
159
+ agent,
160
+ error,
161
+ ),
162
+ };
163
+ }
164
+ agent = thread.definition.name;
165
+ threadId = thread.id;
166
+ definition = thread.definition;
167
+ } else {
168
+ const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
169
+ warn(discovery, ctx);
170
+ definition = discovery.agents.get(agent);
171
+ const invalid = discovery.invalid.get(agent);
172
+ if (!definition) {
173
+ const reason = invalid?.map((item) => item.reason).join("; ") ?? "unknown agent";
174
+ const names = [...discovery.agents.keys()].sort().join(", ") || "none";
175
+ const error = `Agent ${agent} discovery failed: ${reason}. Runnable agents: ${names}`;
176
+ return {
177
+ content: [{ type: "text", text: error }],
178
+ details: emptyDetails(
179
+ agent,
180
+ task,
181
+ "failed",
182
+ "discovery",
183
+ parentModel,
184
+ parentThinking,
185
+ undefined,
186
+ error,
187
+ ),
188
+ };
189
+ }
190
+ threadId = `thread-${nextThreadId++}`;
191
+ }
105
192
  const controller = new AbortController();
106
193
  controllers.add(controller);
107
194
  const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
108
- let release: (() => void) | undefined;
195
+ let releaseThread: (() => void) | undefined;
196
+ let releaseGlobal: (() => void) | undefined;
197
+ let reservedThread: SubagentThread | undefined;
198
+ let phase: "queue" | "startup" = "queue";
109
199
  try {
110
- await onUpdate?.({
111
- content: [{ type: "text", text: `${agent}: waiting` }],
112
- details: {
113
- agent,
114
- task,
115
- status: "waiting",
116
- phase: "queue",
117
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
118
- thinkingLevel,
119
- toolCalls: 0,
120
- actions: [],
121
- omittedActions: 0,
122
- omittedErrors: 0,
123
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
124
- durationMs: 0,
125
- },
126
- });
127
- try {
128
- release = await gate.acquire(combined);
129
- } catch (error) {
130
- const message = error instanceof Error ? error.message : `Agent ${agent} queue failed`;
131
- const details: SubagentDetails = {
132
- agent,
133
- task,
134
- status: combined.aborted ? "aborted" : "failed",
135
- phase: "queue",
136
- model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unavailable",
137
- thinkingLevel,
138
- toolCalls: 0,
139
- actions: [],
140
- omittedActions: 0,
141
- omittedErrors: 0,
142
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
143
- durationMs: 0,
144
- error: message,
145
- };
146
- return { content: [{ type: "text", text: message }], details };
200
+ const waiting = emptyDetails(
201
+ agent,
202
+ task,
203
+ "waiting",
204
+ "queue",
205
+ thread?.model ?? parentModel,
206
+ thread?.thinkingLevel ?? parentThinking,
207
+ threadId,
208
+ undefined,
209
+ );
210
+ await onUpdate?.({ content: [{ type: "text", text: `${agent}: waiting` }], details: waiting });
211
+ if (thread) {
212
+ reservedThread = thread;
213
+ thread.pendingTurns += 1;
214
+ releaseThread = await thread.turnGate.acquire(combined);
147
215
  }
148
- const result = await runSubagent({
149
- definition,
150
- extensionPaths: extensionPathsForTools(pi, definition.tools),
216
+ releaseGlobal = await gate.acquire(combined);
217
+ if (!thread) {
218
+ phase = "startup";
219
+ if (!threadId || !definition) throw new Error("Subagent startup state is incomplete");
220
+ const selectedDefinition = definition;
221
+ if (threads.size >= MAX_RETAINED_THREADS) {
222
+ const evicted = [...threads.values()]
223
+ .filter((item) => item.pendingTurns === 0)
224
+ .sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0];
225
+ if (!evicted) throw new Error("Subagent thread limit reached while all retained threads are busy");
226
+ threads.delete(evicted.id);
227
+ await disposeSubagentThread(evicted);
228
+ }
229
+ thread = await createSubagentThread({
230
+ id: threadId,
231
+ definition: selectedDefinition,
232
+ extensionPaths: extensionPathsForTools(pi, selectedDefinition.tools),
233
+ initialTask: task,
234
+ ctx,
235
+ thinkingLevel: parentThinking,
236
+ signal: combined,
237
+ onWarning: (warning) => {
238
+ const message = `Subagent definition ${selectedDefinition.path}: ${warning}`;
239
+ if (runtimeWarnings.has(message)) return;
240
+ runtimeWarnings.add(message);
241
+ ctx.ui.notify(message, "warning");
242
+ },
243
+ });
244
+ threads.set(thread.id, thread);
245
+ reservedThread = thread;
246
+ thread.pendingTurns += 1;
247
+ }
248
+ if (!thread) throw new Error("Subagent thread startup failed");
249
+ const result = await runSubagentTurn({
250
+ thread,
151
251
  task,
152
- ctx,
153
- thinkingLevel,
252
+ initial: thread.turns === 0,
154
253
  signal: combined,
155
- onWarning: (warning) => {
156
- const message = `Subagent definition ${definition.path}: ${warning}`;
157
- if (runtimeWarnings.has(message)) return;
158
- runtimeWarnings.add(message);
159
- ctx.ui.notify(message, "warning");
160
- },
161
254
  onUpdate: (details) =>
162
255
  onUpdate?.({
163
256
  content: [
@@ -169,9 +262,33 @@ export default function subagentExtension(pi: ExtensionAPI): void {
169
262
  details,
170
263
  }),
171
264
  });
172
- return { content: [{ type: "text", text: result.content }], details: result.details };
265
+ return {
266
+ content: [
267
+ {
268
+ type: "text",
269
+ text: `Thread: ${thread.id}\nReuse with subagent({ thread: "${thread.id}", task: "..." })\n\n${result.content}`,
270
+ },
271
+ ],
272
+ details: result.details,
273
+ };
274
+ } catch (error) {
275
+ const message = error instanceof Error ? error.message : `Agent ${agent} ${phase} failed`;
276
+ const status = combined.aborted ? "aborted" : "failed";
277
+ const details = emptyDetails(
278
+ agent,
279
+ task,
280
+ status,
281
+ phase,
282
+ thread?.model ?? parentModel,
283
+ thread?.thinkingLevel ?? parentThinking,
284
+ thread?.id ?? threadId,
285
+ message,
286
+ );
287
+ return { content: [{ type: "text", text: message }], details };
173
288
  } finally {
174
- release?.();
289
+ if (reservedThread) reservedThread.pendingTurns -= 1;
290
+ releaseGlobal?.();
291
+ releaseThread?.();
175
292
  controllers.delete(controller);
176
293
  }
177
294
  },
@@ -200,12 +317,17 @@ export default function subagentExtension(pi: ExtensionAPI): void {
200
317
  const details = event.details as SubagentDetails | undefined;
201
318
  if (details?.status === "failed" || details?.status === "aborted") return { isError: true };
202
319
  });
203
- pi.on("session_start", () => {
320
+ pi.on("session_start", async () => {
321
+ for (const controller of controllers) controller.abort();
322
+ controllers.clear();
323
+ await disposeThreads();
324
+ nextThreadId = 1;
204
325
  rowState.clear();
205
326
  runtimeWarnings.clear();
206
327
  });
207
- pi.on("session_shutdown", () => {
328
+ pi.on("session_shutdown", async () => {
208
329
  for (const controller of controllers) controller.abort();
209
330
  controllers.clear();
331
+ await disposeThreads();
210
332
  });
211
333
  }
@@ -12,7 +12,7 @@ const title = (theme: Theme, rowState: ToolRowStateStore, rowId: string, invalid
12
12
  };
13
13
 
14
14
  export function renderSubagentCall(
15
- args: { agent?: string; task?: string },
15
+ args: { agent?: string; thread?: string; task?: string },
16
16
  theme: Theme,
17
17
  context: {
18
18
  executionStarted: boolean;
@@ -29,7 +29,7 @@ export function renderSubagentCall(
29
29
  return text;
30
30
  }
31
31
  const prefix =
32
- `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", args.agent ?? "")}`.trimEnd();
32
+ `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", args.agent ?? args.thread ?? "")}`.trimEnd();
33
33
  const task = args.task?.replace(/\s+/g, " ").trim();
34
34
  text.setText(task ? `${prefix} ${theme.fg("muted", task)}` : prefix);
35
35
  return text;
@@ -47,7 +47,8 @@ export function renderSubagentResult(
47
47
  text.setText("");
48
48
  return text;
49
49
  }
50
- const header = `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", details.agent)} ${theme.fg("muted", `$${details.usage.cost.toFixed(4)} · ${(details.durationMs / 1000).toFixed(1)}s · ${details.toolCalls} tools`)}`;
50
+ const identity = details.threadId ? `${details.agent} · ${details.threadId}` : details.agent;
51
+ const header = `${title(theme, context.rowState, context.rowId, context.invalidate)} ${theme.fg("accent", identity)} ${theme.fg("muted", `$${details.usage.cost.toFixed(4)} · ${(details.durationMs / 1000).toFixed(1)}s · ${details.toolCalls} tools`)}`;
51
52
  if (!expanded) {
52
53
  text.setText(`${header} ${theme.fg("muted", details.task.replace(/\s+/g, " ").trim())}`);
53
54
  return text;
@@ -55,6 +55,7 @@ export interface SubagentUsage {
55
55
  }
56
56
  export interface SubagentDetails {
57
57
  agent: string;
58
+ threadId?: string;
58
59
  status: "waiting" | "running" | "completed" | "failed" | "aborted";
59
60
  phase: "discovery" | "queue" | "startup" | "run" | "output";
60
61
  completionState?: string;
@@ -80,6 +81,20 @@ export interface SubagentDetails {
80
81
  };
81
82
  }
82
83
 
84
+ export interface SubagentThread {
85
+ id: string;
86
+ definition: AgentDefinition;
87
+ session: AgentSession;
88
+ cwd: string;
89
+ model: string;
90
+ thinkingLevel: string;
91
+ initialTask: string;
92
+ turns: number;
93
+ turnGate: FifoGate;
94
+ pendingTurns: number;
95
+ lastUsedAt: number;
96
+ }
97
+
83
98
  export function extensionPathsForTools(pi: ExtensionAPI, tools: readonly string[]): string[] {
84
99
  const selected = new Set(tools);
85
100
  return [
@@ -154,31 +169,26 @@ function textOf(message: AssistantMessage): string {
154
169
  .join("");
155
170
  }
156
171
 
157
- function assistants(session: AgentSession): AssistantMessage[] {
158
- return session.messages.filter((message): message is AssistantMessage => message.role === "assistant");
159
- }
160
-
161
- export async function runSubagent(options: {
172
+ export async function createSubagentThread(options: {
173
+ id: string;
162
174
  definition: AgentDefinition;
163
175
  extensionPaths: readonly string[];
164
- task: string;
176
+ initialTask: string;
165
177
  ctx: ExtensionContext;
166
178
  thinkingLevel: string;
167
179
  signal: AbortSignal;
168
- onUpdate?: (details: SubagentDetails) => void | Promise<void>;
169
180
  onWarning?: (warning: string) => void;
170
- }): Promise<{ content: string; details: SubagentDetails }> {
181
+ }): Promise<SubagentThread> {
171
182
  const {
183
+ id,
172
184
  definition,
173
185
  extensionPaths,
174
- task,
186
+ initialTask,
175
187
  ctx,
176
188
  thinkingLevel: parentThinkingLevel,
177
189
  signal,
178
- onUpdate,
179
190
  onWarning,
180
191
  } = options;
181
- const started = Date.now();
182
192
  let model = ctx.model;
183
193
  let thinkingLevel = parentThinkingLevel;
184
194
  if (definition.model) {
@@ -205,34 +215,7 @@ export async function runSubagent(options: {
205
215
  else thinkingLevel = definition.thinking;
206
216
  }
207
217
  const modelName = model ? `${model.provider}/${model.id}` : "unavailable";
208
- const usage: SubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
209
- const details: SubagentDetails = {
210
- agent: definition.name,
211
- status: "running",
212
- phase: "startup",
213
- task,
214
- model: modelName,
215
- thinkingLevel,
216
- toolCalls: 0,
217
- actions: [],
218
- omittedActions: 0,
219
- omittedErrors: 0,
220
- usage,
221
- durationMs: 0,
222
- };
223
218
  let session: AgentSession | undefined;
224
- let unsubscribe: (() => void) | undefined;
225
- let updateChain = Promise.resolve();
226
- let lastTextUpdate = 0;
227
- const publish = (force = false) => {
228
- const now = Date.now();
229
- if (!force && now - lastTextUpdate < 100) return;
230
- lastTextUpdate = now;
231
- details.durationMs = now - started;
232
- const snapshot = structuredClone(details);
233
- snapshot.actions = snapshot.actions.slice(-5);
234
- updateChain = updateChain.then(() => onUpdate?.(snapshot)).catch(() => undefined);
235
- };
236
219
  try {
237
220
  if (!model) throw new Error(`Agent ${definition.name} startup failed: parent has no model`);
238
221
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
@@ -267,12 +250,78 @@ export async function runSubagent(options: {
267
250
  `Agent ${definition.name} startup failed: unavailable tools: ${missing.join(", ") || "active tool mismatch"}`,
268
251
  );
269
252
  }
270
- details.phase = "run";
253
+ return {
254
+ id,
255
+ definition,
256
+ session,
257
+ cwd: ctx.cwd,
258
+ model: modelName,
259
+ thinkingLevel,
260
+ initialTask,
261
+ turns: 0,
262
+ turnGate: new FifoGate(1),
263
+ pendingTurns: 0,
264
+ lastUsedAt: Date.now(),
265
+ };
266
+ } catch (error) {
267
+ if (session?.isStreaming) await session.abort().catch(() => undefined);
268
+ session?.dispose();
269
+ throw error;
270
+ }
271
+ }
272
+
273
+ export async function disposeSubagentThread(thread: SubagentThread): Promise<void> {
274
+ if (thread.session.isStreaming) await thread.session.abort().catch(() => undefined);
275
+ thread.session.dispose();
276
+ }
277
+
278
+ export async function runSubagentTurn(options: {
279
+ thread: SubagentThread;
280
+ task: string;
281
+ initial: boolean;
282
+ signal: AbortSignal;
283
+ onUpdate?: (details: SubagentDetails) => void | Promise<void>;
284
+ }): Promise<{ content: string; details: SubagentDetails }> {
285
+ const { thread, task, initial, signal, onUpdate } = options;
286
+ const { definition, session } = thread;
287
+ const started = Date.now();
288
+ const usage: SubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
289
+ const details: SubagentDetails = {
290
+ agent: definition.name,
291
+ threadId: thread.id,
292
+ status: "running",
293
+ phase: "run",
294
+ task,
295
+ model: thread.model,
296
+ thinkingLevel: thread.thinkingLevel,
297
+ toolCalls: 0,
298
+ actions: [],
299
+ omittedActions: 0,
300
+ omittedErrors: 0,
301
+ usage,
302
+ durationMs: 0,
303
+ };
304
+ let unsubscribe: (() => void) | undefined;
305
+ let updateChain = Promise.resolve();
306
+ let lastTextUpdate = 0;
307
+ const turnMessages: AssistantMessage[] = [];
308
+ const publish = (force = false) => {
309
+ const now = Date.now();
310
+ if (!force && now - lastTextUpdate < 100) return;
311
+ lastTextUpdate = now;
312
+ details.durationMs = now - started;
313
+ const snapshot = structuredClone(details);
314
+ snapshot.actions = snapshot.actions.slice(-5);
315
+ updateChain = updateChain.then(() => onUpdate?.(snapshot)).catch(() => undefined);
316
+ };
317
+ try {
271
318
  const actionById = new Map<string, string>();
272
319
  unsubscribe = session.subscribe((event: AgentSessionEvent) => {
273
320
  if (event.type === "message_update" && event.message.role === "assistant") {
274
321
  details.response = capped(textOf(event.message), PREVIEW_LIMIT);
275
322
  publish();
323
+ } else if (event.type === "message_end" && event.message.role === "assistant") {
324
+ turnMessages.push(event.message);
276
325
  } else if (event.type === "tool_execution_start") {
277
326
  details.toolCalls += 1;
278
327
  const summary = `${event.toolName} ${capped(event.args)}`.trim();
@@ -302,14 +351,15 @@ export async function runSubagent(options: {
302
351
  signal.addEventListener("abort", abort, { once: true });
303
352
  try {
304
353
  await session.prompt(
305
- `You are an isolated delegated child agent. Stay within the delegated task and return only the requested result.\n\n## Agent instructions\n${definition.prompt}\n\n## Delegated task\n${task}`,
354
+ initial
355
+ ? `You are an isolated delegated child agent. Stay within the delegated task and return only the requested result.\n\n## Agent instructions\n${definition.prompt}\n\n## Delegated task\n${task}`
356
+ : `Continue the existing delegated work using the context already in this thread. Return only the requested result.\n\n## Parent follow-up\n${task}`,
306
357
  { expandPromptTemplates: false },
307
358
  );
308
359
  } finally {
309
360
  signal.removeEventListener("abort", abort);
310
361
  }
311
- const messages = assistants(session);
312
- for (const message of messages) {
362
+ for (const message of turnMessages) {
313
363
  usage.input += message.usage.input;
314
364
  usage.output += message.usage.output;
315
365
  usage.cacheRead += message.usage.cacheRead;
@@ -317,7 +367,7 @@ export async function runSubagent(options: {
317
367
  usage.cost += message.usage.cost.total;
318
368
  usage.turns += 1;
319
369
  }
320
- const terminal = messages.at(-1);
370
+ const terminal = turnMessages.at(-1);
321
371
  if (!terminal) throw new Error(`Agent ${definition.name} run failed: no terminal assistant response`);
322
372
  const response = textOf(terminal);
323
373
  if (terminal.stopReason === "aborted") {
@@ -374,7 +424,8 @@ export async function runSubagent(options: {
374
424
  return { content: details.error, details };
375
425
  } finally {
376
426
  unsubscribe?.();
377
- if (session?.isStreaming) await session.abort().catch(() => undefined);
378
- session?.dispose();
427
+ thread.turns += 1;
428
+ thread.lastUsedAt = Date.now();
429
+ if (session.isStreaming) await session.abort().catch(() => undefined);
379
430
  }
380
431
  }
@@ -28,7 +28,7 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Context validation is disabled by default; when enabled, Tau checks changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Entry `files` are autoread; entry `anchors` are unloaded navigation paths the agent can inspect when needed. Context validation is disabled by default; when enabled, Tau checks both classes for changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
@@ -92,7 +92,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
92
92
 
93
93
  ## subagent
94
94
 
95
- 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.
95
+ Gives Tau a subagent delegation tool for isolated, focused work. Tau can continue a retained child thread when follow-up work depends on its prior reads and reasoning. 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 agents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
96
96
 
97
97
  ## tau-help
98
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@shanepadgett/tau-tui": "0.12.0",
31
+ "@shanepadgett/tau-tui": "0.14.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },