@shanepadgett/tau-agent 0.7.2 → 0.8.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.
@@ -116,11 +116,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
116
116
  name: "list_windows",
117
117
  label: "List Windows",
118
118
  description:
119
- "List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
120
- promptSnippet: "Discover visible macOS application windows and their IDs",
121
- promptGuidelines: [
122
- "Use list_windows to discover graphical applications and select an exact window before calling screenshot_window.",
123
- ],
119
+ "List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Use list_windows to discover exact window IDs and application PIDs before screenshot_window or activate_app. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
124
120
  parameters: listWindowsSchema,
125
121
  async execute(_toolCallId, _params, signal) {
126
122
  if (process.platform !== "darwin") throw new Error("list_windows is only available on macOS");
@@ -158,9 +154,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
158
154
  name: "screenshot_window",
159
155
  label: "Screenshot Window",
160
156
  description:
161
- "Capture one visible macOS window by its list_windows ID, resize it to fit within 1568×1568 pixels, save it as PNG, and inspect the image.",
162
- promptSnippet: "Capture and inspect an exact visible macOS window by window ID",
163
- promptGuidelines: ["Call list_windows before screenshot_window; pass its exact window_id and a PNG path."],
157
+ "Capture one visible macOS window by an exact ID returned by list_windows, resize it to fit within 1568×1568 pixels, save it to the required PNG path, and inspect the image. Call list_windows first.",
164
158
  parameters: screenshotWindowSchema,
165
159
  async execute(_toolCallId, params: ScreenshotWindowParams, signal, onUpdate, ctx) {
166
160
  if (process.platform !== "darwin") throw new Error("screenshot_window is only available on macOS");
@@ -219,11 +213,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
219
213
  name: "activate_app",
220
214
  label: "Activate App",
221
215
  description:
222
- "Bring a running macOS application and its windows to the foreground by process ID. Use a PID returned by list_windows.",
223
- promptSnippet: "Bring a listed macOS application to the foreground by process ID",
224
- promptGuidelines: [
225
- "Use activate_app only when foregrounding a listed application is needed for visual validation because it changes user focus.",
226
- ],
216
+ "Bring a running macOS application and its windows to the foreground by a process ID returned by list_windows. Use only when foregrounding is required for visual validation because activate_app changes user focus.",
227
217
  parameters: activateAppSchema,
228
218
  async execute(_toolCallId, params, signal) {
229
219
  if (process.platform !== "darwin") throw new Error("activate_app is only available on macOS");
@@ -15,6 +15,45 @@ export interface ContextEntry {
15
15
  path: string;
16
16
  }
17
17
 
18
+ const CONTEXT_IGNORED_FILENAMES = new Set([
19
+ "bun.lock",
20
+ "bun.lockb",
21
+ "Cargo.lock",
22
+ "composer.lock",
23
+ "flake.lock",
24
+ "Gemfile.lock",
25
+ "go.sum",
26
+ "mix.lock",
27
+ "npm-shrinkwrap.json",
28
+ "package-lock.json",
29
+ "Package.resolved",
30
+ "Pipfile.lock",
31
+ "pnpm-lock.yaml",
32
+ "Podfile.lock",
33
+ "poetry.lock",
34
+ "pubspec.lock",
35
+ "uv.lock",
36
+ "yarn.lock",
37
+ ]);
38
+
39
+ export function isContextEligiblePath(path: string): boolean {
40
+ return (
41
+ path !== "LICENSE" &&
42
+ !CONTEXT_IGNORED_FILENAMES.has(basename(path)) &&
43
+ path !== ".pi/tau/ideas.jsonl" &&
44
+ path !== ".working" &&
45
+ !path.startsWith(".working/") &&
46
+ path !== ".pi/contexts" &&
47
+ !path.startsWith(".pi/contexts/")
48
+ );
49
+ }
50
+
51
+ export function isSensitiveContextPath(path: string): boolean {
52
+ const name = basename(path);
53
+ if (name === ".env.example" || name === ".env.sample") return false;
54
+ return name === ".env" || name.startsWith(".env.") || /\.(?:pem|key|crt|p12|pfx)$/i.test(name);
55
+ }
56
+
18
57
  export async function pathExists(path: string): Promise<boolean> {
19
58
  try {
20
59
  await access(path);
@@ -5,6 +5,7 @@ import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
6
  import { emitTauEvent } from "../../shared/events.ts";
7
7
  import { createInjectedContext } from "../../shared/injected-context.ts";
8
+ import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
8
9
  import { ContextPanel } from "./panel.ts";
9
10
  import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
10
11
  import { runContextSync, type ContextSyncDetails } from "./sync.ts";
@@ -22,6 +23,15 @@ function compactResult(details: ContextSyncDetails) {
22
23
 
23
24
  export default function contextExtension(pi: ExtensionAPI): void {
24
25
  let active: ContextEntry[] = [];
26
+ const activeToolExecutions = new Map<string, { toolName: string; done: Promise<void>; complete: () => void }>();
27
+ const unregisterPrompt = registerTauSystemPromptContribution({
28
+ id: "context.selected-authority",
29
+ order: 100,
30
+ render: () =>
31
+ active.length
32
+ ? "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."
33
+ : undefined,
34
+ });
25
35
 
26
36
  pi.registerCommand("context", {
27
37
  description: "Select repository context entries and inject their files",
@@ -94,7 +104,12 @@ export default function contextExtension(pi: ExtensionAPI): void {
94
104
  label: "context_sync",
95
105
  description: "Synchronize repository context from current Git changes.",
96
106
  parameters: contextSyncParams,
97
- async execute(_id, _params, _signal, onUpdate, ctx) {
107
+ async execute(id, _params, _signal, onUpdate, ctx) {
108
+ await Promise.all(
109
+ [...activeToolExecutions]
110
+ .filter(([toolCallId, execution]) => toolCallId !== id && execution.toolName !== "context_sync")
111
+ .map(([, execution]) => execution.done),
112
+ );
98
113
  return compactResult(
99
114
  await runContextSync(pi, ctx, (status) =>
100
115
  onUpdate?.({ content: [{ type: "text", text: status }], details: undefined }),
@@ -131,6 +146,20 @@ export default function contextExtension(pi: ExtensionAPI): void {
131
146
  }),
132
147
  );
133
148
 
149
+ pi.on("tool_execution_start", (event) => {
150
+ let complete = () => {};
151
+ const done = new Promise<void>((resolve) => {
152
+ complete = resolve;
153
+ });
154
+ activeToolExecutions.set(event.toolCallId, { toolName: event.toolName, done, complete });
155
+ });
156
+
157
+ pi.on("tool_execution_end", (event) => {
158
+ const execution = activeToolExecutions.get(event.toolCallId);
159
+ activeToolExecutions.delete(event.toolCallId);
160
+ execution?.complete();
161
+ });
162
+
134
163
  pi.on("session_start", async (_event, ctx) => {
135
164
  const root = await findProjectRoot(ctx.cwd);
136
165
  const entries = await loadContextEntries(root);
@@ -146,11 +175,9 @@ export default function contextExtension(pi: ExtensionAPI): void {
146
175
  : [];
147
176
  active = entries.filter((entry) => ids.includes(entry.id));
148
177
  });
149
- pi.on("before_agent_start", (event) =>
150
- active.length
151
- ? {
152
- 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.`,
153
- }
154
- : undefined,
155
- );
178
+ pi.on("session_shutdown", () => {
179
+ unregisterPrompt();
180
+ for (const execution of activeToolExecutions.values()) execution.complete();
181
+ activeToolExecutions.clear();
182
+ });
156
183
  }
@@ -9,6 +9,8 @@ import { generateToolValidated, resolveCandidates } from "../../shared/model-fal
9
9
  import { truncAt } from "../../shared/text.ts";
10
10
  import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
11
11
  import {
12
+ isContextEligiblePath,
13
+ isSensitiveContextPath,
12
14
  loadContextEntries,
13
15
  normalizeProjectPath,
14
16
  pathExists,
@@ -130,6 +132,14 @@ export async function runContextSync(
130
132
  pi: ExtensionAPI,
131
133
  ctx: ExtensionContext,
132
134
  onStatus?: (status: string) => void | Promise<void>,
135
+ ): Promise<ContextSyncDetails> {
136
+ return withSyncLock(() => runContextSyncLocked(pi, ctx, onStatus));
137
+ }
138
+
139
+ async function runContextSyncLocked(
140
+ pi: ExtensionAPI,
141
+ ctx: ExtensionContext,
142
+ onStatus?: (status: string) => void | Promise<void>,
133
143
  ): Promise<ContextSyncDetails> {
134
144
  if (!ctx.isProjectTrusted()) throw new Error("Context sync requires a trusted project");
135
145
  await onStatus?.("Inspecting repository context");
@@ -138,6 +148,7 @@ export async function runContextSync(
138
148
  if (!status) throw new Error("No Git repository found");
139
149
  if (status.fileCount === 0) return noChange("Existing context mappings already fit the changed scope.");
140
150
  const evidence = await collectSyncEvidence(git, status.root);
151
+ if (evidence.files.length === 0) return noChange("Changed files are outside context catalog scope.");
141
152
  const prompt = buildContextSyncPrompt(evidence);
142
153
  const plan = await generateToolValidated(
143
154
  ctx,
@@ -150,15 +161,13 @@ export async function runContextSync(
150
161
  );
151
162
  if (plan.outcome === "no-change") return noChange(plan.reason);
152
163
  await onStatus?.("Applying context catalog changes");
153
- return withSyncLock(async () => {
154
- return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
155
- const currentEntries = await loadContextEntries(evidence.root);
156
- const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
157
- if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
158
- throw new Error("Repository changed during context sync. Rerun context sync.");
159
- if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
160
- throw new Error("Context catalog changed during context sync. Rerun context sync.");
161
- });
164
+ return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
165
+ const currentEntries = await loadContextEntries(evidence.root);
166
+ const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
167
+ if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
168
+ throw new Error("Repository changed during context sync. Rerun context sync.");
169
+ if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
170
+ throw new Error("Context catalog changed during context sync. Rerun context sync.");
162
171
  });
163
172
  }
164
173
 
@@ -197,6 +206,7 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
197
206
  root,
198
207
  files.filter((file) => dirtyExisting.has(file.path)),
199
208
  );
209
+ for (const path of dependencies) if (!isContextEligiblePath(path)) dependencies.delete(path);
200
210
  const affectedIds = new Set<string>();
201
211
  for (const entry of entries) {
202
212
  if (
@@ -211,7 +221,9 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
211
221
  const missingPaths = new Set<string>();
212
222
  for (const entry of entries)
213
223
  for (const path of entry.files) if (!(await isFile(join(root, path)))) missingPaths.add(path);
214
- const siblingFiles = siblingEntries.flatMap((entry) => entry.files).filter((path) => !missingPaths.has(path));
224
+ const siblingFiles = siblingEntries
225
+ .flatMap((entry) => entry.files)
226
+ .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path));
215
227
  const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
216
228
  const structuralPreviews = new Map<string, string>();
217
229
  for (const path of [...new Set(siblingFiles)].sort()) {
@@ -282,7 +294,21 @@ async function collectDirtyFiles(
282
294
  index++;
283
295
  }
284
296
  }
285
- const sorted = parsed.sort((a, b) => a.path.localeCompare(b.path));
297
+ const sensitive = parsed.filter(
298
+ (file) =>
299
+ file.kind !== "deleted" &&
300
+ (isSensitiveContextPath(file.path) || (file.oldPath !== undefined && isSensitiveContextPath(file.oldPath))),
301
+ );
302
+ if (sensitive.length > 0)
303
+ throw new Error(
304
+ `Sensitive files cannot be inspected by context sync:\n${sensitive.map((file) => `- ${file.path}`).join("\n")}`,
305
+ );
306
+ const sorted = parsed
307
+ .filter(
308
+ (file) =>
309
+ isContextEligiblePath(file.path) || (file.oldPath !== undefined && isContextEligiblePath(file.oldPath)),
310
+ )
311
+ .sort((a, b) => a.path.localeCompare(b.path));
286
312
  const result: SyncDirtyFile[] = [];
287
313
  for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
288
314
  result.push(
@@ -627,16 +653,16 @@ export async function applyContextSyncPlan(
627
653
  for (const path of paths) originals.set(path, (await pathExists(path)) ? await readFile(path) : undefined);
628
654
  const temporaryFiles = new Map<string, string>();
629
655
  try {
630
- for (const path of paths) {
631
- const output = outputs.get(path);
632
- if (output === undefined) continue;
633
- await mkdir(dirname(path), { recursive: true });
634
- const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
635
- await writeFile(temporary, output, "utf8");
636
- temporaryFiles.set(path, temporary);
637
- }
638
656
  await acquireMutationQueues(lockPaths, async () => {
639
657
  await verifyFreshness?.();
658
+ for (const path of paths) {
659
+ const output = outputs.get(path);
660
+ if (output === undefined) continue;
661
+ await mkdir(dirname(path), { recursive: true });
662
+ const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
663
+ await writeFile(temporary, output, "utf8");
664
+ temporaryFiles.set(path, temporary);
665
+ }
640
666
  const changed: string[] = [];
641
667
  try {
642
668
  for (const path of paths) {
@@ -41,14 +41,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
41
41
  name: "image_gen",
42
42
  label: "Image Generation",
43
43
  description:
44
- "Generate a new raster image from a prompt, or edit one to three local raster images. Uses the xAI Grok subscription OAuth login, saves the result under Tau's external image store unless an explicit path is provided, and returns the image for inspection.",
45
- promptSnippet: "Generate or edit raster images with Grok Imagine",
46
- promptGuidelines: [
47
- "Use image_gen when the user asks for a generated raster image or an AI edit of local raster images.",
48
- "Omit referenced_image_paths when image_gen should create a new image.",
49
- "Pass one to three local paths in referenced_image_paths when image_gen should edit or compose existing images.",
50
- "Omit path for temporary external storage. Pass path only when the user wants the generated image saved in their repository or another explicit location.",
51
- ],
44
+ "Generate a requested raster image or AI-edit existing images with the xAI Grok subscription OAuth login. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
52
45
  parameters: imageGenSchema,
53
46
  async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
54
47
  signal?.throwIfAborted();
@@ -4,6 +4,7 @@ import { type ExecResult, type ExtensionAPI, keyText, type Theme } from "@earend
4
4
  import { Box, Text } from "@earendil-works/pi-tui";
5
5
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
6
6
  import { resolveProjectRoot } from "../../shared/settings/paths.ts";
7
+ import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
7
8
  import silentCommandRunnerSettings from "./settings.ts";
8
9
 
9
10
  const MESSAGE_TYPE = "tau:silent-command-runner";
@@ -79,18 +80,17 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
79
80
  let turnPaths = new Set<string>();
80
81
  let run: Promise<void> | undefined;
81
82
  let abortController: AbortController | undefined;
83
+ const unregisterPrompt = registerTauSystemPromptContribution({
84
+ id: "silent-command-runner.instructions",
85
+ order: 200,
86
+ render: () =>
87
+ settings.enabled && settings.commands.length > 0 ? formatSilentCheckPrompt(settings.commands) : undefined,
88
+ });
82
89
 
83
90
  pi.registerMessageRenderer<FailureDetails>(MESSAGE_TYPE, (message, { expanded }, theme) =>
84
91
  renderFailure(asFailureDetails(message.details), expanded, theme),
85
92
  );
86
93
 
87
- pi.on("before_agent_start", (event) => {
88
- if (!settings.enabled || settings.commands.length === 0) return;
89
- const prompt = formatSilentCheckPrompt(settings.commands);
90
- event.systemPromptOptions.appendSystemPrompt = appendPrompt(event.systemPromptOptions.appendSystemPrompt, prompt);
91
- return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
92
- });
93
-
94
94
  pi.on("session_start", async (_event, ctx) => {
95
95
  settings = normalizeSettings(await loadTauExtensionSettings(ctx, silentCommandRunnerSettings));
96
96
  turnStart = Date.now();
@@ -122,6 +122,7 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
122
122
  });
123
123
 
124
124
  pi.on("session_shutdown", () => {
125
+ unregisterPrompt();
125
126
  abortController?.abort();
126
127
  abortController = undefined;
127
128
  run = undefined;
@@ -206,10 +207,6 @@ function formatSilentCheckPrompt(commands: readonly CommandConfig[]): string {
206
207
  ].join("\n");
207
208
  }
208
209
 
209
- function appendPrompt(current: string | undefined, prompt: string): string {
210
- return current ? `${current}\n\n${prompt}` : prompt;
211
- }
212
-
213
210
  function formatSilentCheckCommand(command: CommandConfig): string {
214
211
  return [
215
212
  `- ${command.name}: ${command.command}`,
@@ -2,7 +2,7 @@
2
2
 
3
3
  Soul replaces Pi's default assistant prompt with Rok: caveman voice plus old-code senior judgment.
4
4
 
5
- It keeps the core prompt stable for cache reuse and captures runtime and project context once per session.
5
+ It keeps the core prompt stable for cache reuse. Soul supplies the current local date and the initial root directory snapshot as hidden session context; the working directory and project instructions remain in the system prompt.
6
6
 
7
7
  Disable it in Tau settings:
8
8
 
@@ -1,8 +1,26 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
3
- import { buildRokPrompt, freezeRuntimeContext, type RuntimeContext } from "./prompt.ts";
3
+ import { collectTauSystemPromptContributions } from "../../shared/system-prompt-contributions.ts";
4
+ import {
5
+ buildRokPrompt,
6
+ fingerprintRuntimeSnapshot,
7
+ formatLocalDateKey,
8
+ formatLocalDisplayDate,
9
+ formatRuntimeContextMessage,
10
+ freezeRuntimeContext,
11
+ type RuntimeContext,
12
+ } from "./prompt.ts";
4
13
  import soulSettings from "./settings.ts";
5
14
 
15
+ const RUNTIME_CONTEXT_TYPE = "tau.runtime-context";
16
+
17
+ interface RuntimeContextMessageDetails {
18
+ version: 1;
19
+ dateKey: string;
20
+ snapshotHash: string;
21
+ includesSnapshot: boolean;
22
+ }
23
+
6
24
  export default function soulExtension(pi: ExtensionAPI): void {
7
25
  let enabled = true;
8
26
  let runtimeContext: RuntimeContext | undefined;
@@ -12,9 +30,72 @@ export default function soulExtension(pi: ExtensionAPI): void {
12
30
  runtimeContext = freezeRuntimeContext(ctx.cwd);
13
31
  });
14
32
 
15
- pi.on("before_agent_start", (event, ctx) => {
16
- if (!enabled) return undefined;
33
+ pi.on("before_agent_start", async (event, ctx) => {
17
34
  runtimeContext ??= freezeRuntimeContext(ctx.cwd);
18
- return { systemPrompt: buildRokPrompt(event.systemPromptOptions, runtimeContext) };
35
+ // Pi reuses base prompt options, so Tau-owned additions compose here without accumulating mutations.
36
+ const contributions = await collectTauSystemPromptContributions(event, ctx);
37
+ const basePrompt = enabled ? buildRokPrompt(event.systemPromptOptions, runtimeContext) : event.systemPrompt;
38
+ const systemPrompt = [basePrompt, ...contributions].filter((block) => block.trim()).join("\n\n");
39
+
40
+ const now = new Date();
41
+ const dateKey = formatLocalDateKey(now);
42
+ const snapshotHash = fingerprintRuntimeSnapshot(runtimeContext);
43
+ let hasDate = false;
44
+ let hasSnapshot = false;
45
+ for (const entry of ctx.sessionManager.buildContextEntries()) {
46
+ const details = runtimeContextDetails(entry);
47
+ if (!details) continue;
48
+ if (details.dateKey === dateKey) hasDate = true;
49
+ if (details.includesSnapshot && details.snapshotHash === snapshotHash) hasSnapshot = true;
50
+ }
51
+
52
+ const includeSnapshot = !hasSnapshot;
53
+ const message =
54
+ hasDate && hasSnapshot
55
+ ? undefined
56
+ : {
57
+ customType: RUNTIME_CONTEXT_TYPE,
58
+ content: formatRuntimeContextMessage(
59
+ formatLocalDisplayDate(now),
60
+ includeSnapshot ? runtimeContext.rootSnapshot : undefined,
61
+ ),
62
+ display: false,
63
+ details: {
64
+ version: 1,
65
+ dateKey,
66
+ snapshotHash,
67
+ includesSnapshot: includeSnapshot,
68
+ } satisfies RuntimeContextMessageDetails,
69
+ };
70
+
71
+ if (!enabled && contributions.length === 0 && !message) return undefined;
72
+ return {
73
+ ...(enabled || contributions.length > 0 ? { systemPrompt } : {}),
74
+ ...(message ? { message } : {}),
75
+ };
19
76
  });
20
77
  }
78
+
79
+ function runtimeContextDetails(value: unknown): RuntimeContextMessageDetails | undefined {
80
+ if (!value || typeof value !== "object") return undefined;
81
+ const entry = value as Record<string, unknown>;
82
+ if (entry.type !== "custom_message" || entry.customType !== RUNTIME_CONTEXT_TYPE || entry.display !== false) {
83
+ return undefined;
84
+ }
85
+ if (!entry.details || typeof entry.details !== "object") return undefined;
86
+ const details = entry.details as Record<string, unknown>;
87
+ if (
88
+ details.version !== 1 ||
89
+ typeof details.dateKey !== "string" ||
90
+ typeof details.snapshotHash !== "string" ||
91
+ typeof details.includesSnapshot !== "boolean"
92
+ ) {
93
+ return undefined;
94
+ }
95
+ return {
96
+ version: 1,
97
+ dateKey: details.dateKey,
98
+ snapshotHash: details.snapshotHash,
99
+ includesSnapshot: details.includesSnapshot,
100
+ };
101
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -19,7 +20,6 @@ interface SnapshotEntry {
19
20
  }
20
21
 
21
22
  export interface RuntimeContext {
22
- date: string;
23
23
  cwd: string;
24
24
  rootSnapshot: readonly string[];
25
25
  }
@@ -73,21 +73,50 @@ export function buildRokPrompt(options: BuildSystemPromptOptions, runtimeContext
73
73
  }
74
74
 
75
75
  export function freezeRuntimeContext(cwd: string): RuntimeContext {
76
- return { date: formatDate(new Date()), cwd: cwd.replace(/\\/g, "/"), rootSnapshot: listRootSnapshot(cwd) };
76
+ return { cwd: cwd.replace(/\\/g, "/"), rootSnapshot: listRootSnapshot(cwd) };
77
77
  }
78
78
 
79
- function formatDate(date: Date): string {
79
+ export function formatLocalDateKey(date: Date): string {
80
80
  const year = date.getFullYear();
81
81
  const month = String(date.getMonth() + 1).padStart(2, "0");
82
82
  const day = String(date.getDate()).padStart(2, "0");
83
83
  return `${year}-${month}-${day}`;
84
84
  }
85
85
 
86
+ export function formatLocalDisplayDate(date: Date): string {
87
+ const months = [
88
+ "January",
89
+ "February",
90
+ "March",
91
+ "April",
92
+ "May",
93
+ "June",
94
+ "July",
95
+ "August",
96
+ "September",
97
+ "October",
98
+ "November",
99
+ "December",
100
+ ];
101
+ return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
102
+ }
103
+
104
+ export function fingerprintRuntimeSnapshot(context: RuntimeContext): string {
105
+ return createHash("sha256")
106
+ .update(JSON.stringify({ version: 1, cwd: context.cwd, paths: context.rootSnapshot }))
107
+ .digest("hex");
108
+ }
109
+
110
+ export function formatRuntimeContextMessage(displayDate: string, rootSnapshot: readonly string[] | undefined): string {
111
+ const blocks = [`Current local date: ${displayDate}`];
112
+ if (rootSnapshot?.length) {
113
+ blocks.push(`Root directory snapshot (depth 2):\n${rootSnapshot.map((path) => `- ${path}`).join("\n")}`);
114
+ }
115
+ return blocks.join("\n");
116
+ }
117
+
86
118
  function formatRuntimeContext(context: RuntimeContext): string {
87
- const rootSnapshot = context.rootSnapshot.length
88
- ? `\nRoot directory snapshot (depth 2):\n${context.rootSnapshot.map((path) => `- ${path}`).join("\n")}`
89
- : "";
90
- return `Current date: ${context.date}\nCurrent working directory: ${context.cwd}${rootSnapshot}`;
119
+ return `Current working directory: ${context.cwd}`;
91
120
  }
92
121
 
93
122
  function listRootSnapshot(cwd: string): string[] {
@@ -1,6 +1,7 @@
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 { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
4
5
  import { discoverAgents, type AgentDiscovery } from "./agents.ts";
5
6
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
6
7
  import { extensionPathsForTools, FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
@@ -33,6 +34,19 @@ export default function subagentExtension(pi: ExtensionAPI): void {
33
34
  }
34
35
  for (const path of fingerprints.keys()) if (!current.has(path)) fingerprints.delete(path);
35
36
  };
37
+ const unregisterPrompt = registerTauSystemPromptContribution({
38
+ id: "subagent.catalog",
39
+ order: 300,
40
+ render: async (_event, ctx) => {
41
+ if (!pi.getActiveTools().includes("subagent")) return undefined;
42
+ const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
43
+ warn(discovery, ctx);
44
+ const lines = [...discovery.agents.values()]
45
+ .sort((a, b) => a.name.localeCompare(b.name))
46
+ .map((agent) => `- ${agent.name}: ${agent.description}`);
47
+ return `## 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.`;
48
+ },
49
+ });
36
50
  pi.registerTool(
37
51
  defineTool<typeof params, SubagentDetails>({
38
52
  name: "subagent",
@@ -184,21 +198,6 @@ export default function subagentExtension(pi: ExtensionAPI): void {
184
198
  },
185
199
  }),
186
200
  );
187
- pi.on("before_agent_start", async (event, ctx) => {
188
- if (!pi.getActiveTools().includes("subagent")) return;
189
- const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
190
- warn(discovery, ctx);
191
- const lines = [...discovery.agents.values()]
192
- .sort((a, b) => a.name.localeCompare(b.name))
193
- .map((agent) => `- ${agent.name}: ${agent.description}`);
194
- 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.`;
195
- event.systemPromptOptions.appendSystemPrompt = event.systemPromptOptions.appendSystemPrompt
196
- ? `${event.systemPromptOptions.appendSystemPrompt}\n\n${prompt}`
197
- : prompt;
198
- return {
199
- systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
200
- };
201
- });
202
201
  pi.on("tool_result", (event) => {
203
202
  if (event.toolName !== "subagent") return;
204
203
  const details = event.details as SubagentDetails | undefined;
@@ -209,6 +208,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
209
208
  runtimeWarnings.clear();
210
209
  });
211
210
  pi.on("session_shutdown", () => {
211
+ unregisterPrompt();
212
212
  for (const controller of controllers) controller.abort();
213
213
  controllers.clear();
214
214
  });
@@ -98,13 +98,17 @@ Adds `/tau-help` to show this guide as rendered Markdown in the chat.
98
98
 
99
99
  Adds `/tau`, `/tau init [--global|--project]`, and `/tau doctor` for Tau setup and diagnostics.
100
100
 
101
+ ## tool-loader
102
+
103
+ Progressively exposes specialist tools through `load_tools`. Tau normally loads the fixed `web`, `image`, and `appshot` groups itself when needed; supported providers can preserve more prompt-cache reuse.
104
+
101
105
  ## turn-budget
102
106
 
103
107
  Tracks and limits agent turns to keep work bounded.
104
108
 
105
109
  ## web
106
110
 
107
- Gives the agent compact `web_search`, `web_fetch`, and `code_search` tools for web and implementation research.
111
+ Gives the agent compact `websearch`, `webfetch`, and `codesearch` tools for web and implementation research.
108
112
 
109
113
  ## Prompts
110
114
 
@@ -0,0 +1,13 @@
1
+ # Tool Loader
2
+
3
+ Tau progressively exposes specialist tools. Most coding turns do not need web, image, or macOS application schemas, so Pi can load those tools later without discarding supported provider cache prefixes.
4
+
5
+ The agent normally calls `load_tools` itself. Users can also ask Tau to load one of these groups:
6
+
7
+ - `web` for public web and implementation research
8
+ - `image` for raster image generation and editing
9
+ - `appshot` for macOS window discovery, capture, and activation
10
+
11
+ Supported models optimize prompt caching when a group loads. Other models keep the same functional behavior.
12
+
13
+ After changing this extension during development, run `/reload` before testing.
@@ -0,0 +1,134 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { type Static, Type } from "typebox";
4
+
5
+ const CAPABILITIES = ["web", "image", "appshot"] as const;
6
+ type Capability = (typeof CAPABILITIES)[number];
7
+
8
+ const CAPABILITY_TOOLS: Record<Capability, readonly string[]> = {
9
+ web: ["webfetch", "websearch", "codesearch"],
10
+ image: ["image_gen"],
11
+ appshot: ["list_windows", "screenshot_window", "activate_app"],
12
+ };
13
+ const SPECIALIST_TOOLS = CAPABILITIES.flatMap((capability) => CAPABILITY_TOOLS[capability]);
14
+
15
+ const loadToolsSchema = Type.Object(
16
+ {
17
+ capability: StringEnum(CAPABILITIES, {
18
+ description: "Specialist group to load: web, image, or appshot",
19
+ }),
20
+ },
21
+ { additionalProperties: false },
22
+ );
23
+
24
+ type LoadToolsParams = Static<typeof loadToolsSchema>;
25
+
26
+ interface LoadToolsDetails {
27
+ version: 1;
28
+ capability: Capability;
29
+ requestedToolNames: string[];
30
+ addedToolNames: string[];
31
+ }
32
+
33
+ export default function toolLoaderExtension(pi: ExtensionAPI): void {
34
+ let managed = false;
35
+ let allowedSpecialistNames = new Set<string>();
36
+
37
+ pi.registerTool(
38
+ defineTool<typeof loadToolsSchema, LoadToolsDetails>({
39
+ name: "load_tools",
40
+ label: "Load Tools",
41
+ description:
42
+ "Load one Tau specialist tool group for the current session. Groups: web for public web and implementation research; image for raster generation and editing; appshot for macOS window discovery, capture, and activation.",
43
+ promptSnippet: "Load a specialist Tau tool group for web research, image generation, or macOS app inspection",
44
+ promptGuidelines: [
45
+ "Use load_tools before attempting a specialist capability whose tools are not currently available.",
46
+ ],
47
+ parameters: loadToolsSchema,
48
+ async execute(_toolCallId, params: LoadToolsParams) {
49
+ const before = pi.getActiveTools();
50
+ const requested = [...CAPABILITY_TOOLS[params.capability]];
51
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
52
+ const loadable = requested.filter((name) => registered.has(name) && allowedSpecialistNames.has(name));
53
+ if (loadable.length === 0) {
54
+ throw new Error(`No ${params.capability} tools are available in this session's tool configuration.`);
55
+ }
56
+ const beforeSet = new Set(before);
57
+ const next = [...before, ...loadable.filter((name) => !beforeSet.has(name))];
58
+ pi.setActiveTools(next);
59
+ const after = pi.getActiveTools();
60
+ const addedToolNames = after.filter((name) => !beforeSet.has(name));
61
+ const available = requested.filter((name) => after.includes(name));
62
+ const unavailable = requested.filter((name) => !after.includes(name));
63
+ const label = `${params.capability[0]?.toUpperCase()}${params.capability.slice(1)}`;
64
+ const text =
65
+ addedToolNames.length > 0
66
+ ? `Loaded ${params.capability} tools: ${addedToolNames.join(", ")}.`
67
+ : `${label} tools are already loaded: ${available.join(", ")}.`;
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: unavailable.length ? `${text} Unavailable: ${unavailable.join(", ")}.` : text,
73
+ },
74
+ ],
75
+ details: {
76
+ version: 1,
77
+ capability: params.capability,
78
+ requestedToolNames: requested,
79
+ addedToolNames,
80
+ },
81
+ };
82
+ },
83
+ }),
84
+ );
85
+
86
+ pi.on("session_start", (_event, ctx) => {
87
+ const initial = pi.getActiveTools();
88
+ const initialSet = new Set(initial);
89
+ allowedSpecialistNames = new Set(SPECIALIST_TOOLS.filter((name) => initialSet.has(name)));
90
+ managed = initialSet.has("load_tools") && SPECIALIST_TOOLS.every((name) => initialSet.has(name));
91
+ if (managed) restoreActiveTools(pi, initial, loadedCapabilities(ctx.sessionManager.getBranch()));
92
+ });
93
+
94
+ pi.on("session_tree", (_event, ctx) => {
95
+ if (managed) restoreActiveTools(pi, pi.getActiveTools(), loadedCapabilities(ctx.sessionManager.getBranch()));
96
+ });
97
+ }
98
+
99
+ function restoreActiveTools(pi: ExtensionAPI, current: readonly string[], loaded: ReadonlySet<Capability>): void {
100
+ const specialist = new Set(SPECIALIST_TOOLS);
101
+ const next = current.filter((name) => !specialist.has(name));
102
+ for (const capability of CAPABILITIES) {
103
+ if (loaded.has(capability)) next.push(...CAPABILITY_TOOLS[capability]);
104
+ }
105
+ pi.setActiveTools([...new Set(next)]);
106
+ }
107
+
108
+ function loadedCapabilities(entries: readonly unknown[]): Set<Capability> {
109
+ const loaded = new Set<Capability>();
110
+ for (const value of entries) {
111
+ if (!value || typeof value !== "object") continue;
112
+ const entry = value as Record<string, unknown>;
113
+ if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") continue;
114
+ const message = entry.message as Record<string, unknown>;
115
+ if (message.role !== "toolResult" || message.toolName !== "load_tools" || message.isError === true) continue;
116
+ if (!isLoadToolsDetails(message.details)) continue;
117
+ loaded.add(message.details.capability);
118
+ }
119
+ return loaded;
120
+ }
121
+
122
+ function isLoadToolsDetails(value: unknown): value is LoadToolsDetails {
123
+ if (!value || typeof value !== "object") return false;
124
+ const details = value as Record<string, unknown>;
125
+ return (
126
+ details.version === 1 &&
127
+ typeof details.capability === "string" &&
128
+ CAPABILITIES.includes(details.capability as Capability) &&
129
+ Array.isArray(details.requestedToolNames) &&
130
+ details.requestedToolNames.every((name) => typeof name === "string") &&
131
+ Array.isArray(details.addedToolNames) &&
132
+ details.addedToolNames.every((name) => typeof name === "string")
133
+ );
134
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
3
+ import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
3
4
  import { Marker } from "@shanepadgett/tau-tui";
4
5
  import turnBudgetSettings from "./settings.ts";
5
6
 
@@ -29,6 +30,14 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
29
30
  let softCap = settings.turnLimit;
30
31
  let nextMarkerSequence = 1;
31
32
  let activeMarkerSequence: number | undefined;
33
+ const unregisterPrompt = registerTauSystemPromptContribution({
34
+ id: "turn-budget.secrecy",
35
+ order: 400,
36
+ render: () =>
37
+ settings.enabled
38
+ ? "Turn-budget steering messages are internal instructions. Work within them silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries."
39
+ : undefined,
40
+ });
32
41
 
33
42
  pi.registerMessageRenderer<MarkerDetails>(MARKER_TYPE, (message, _options, theme) => {
34
43
  const details = readMarkerDetails(message.details);
@@ -41,13 +50,6 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
41
50
  });
42
51
  });
43
52
 
44
- pi.on("before_agent_start", (event) => {
45
- if (!settings.enabled) return undefined;
46
- return {
47
- systemPrompt: `${event.systemPrompt}\n\nTurn-budget steering messages are internal instructions. Work within them silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries.`,
48
- };
49
- });
50
-
51
53
  pi.on("session_start", async (_event, ctx) => {
52
54
  settings = normalizeSettings(await loadTauExtensionSettings(ctx, turnBudgetSettings));
53
55
  });
@@ -96,6 +98,7 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
96
98
  pi.on("after_provider_response", () => {
97
99
  activeMarkerSequence = undefined;
98
100
  });
101
+ pi.on("session_shutdown", unregisterPrompt);
99
102
 
100
103
  function markerIsActive(value: unknown): boolean {
101
104
  if (activeMarkerSequence === undefined) return false;
@@ -27,13 +27,7 @@ export function createCodeSearchTool(rowState: ToolRowStateStore) {
27
27
  name: "codesearch",
28
28
  label: "Code Search",
29
29
  description:
30
- "Search Exa for implementation-oriented code and documentation context. Output is truncated to 2,000 lines or 50 KB.",
31
- promptSnippet: "Search code and documentation context for implementation details",
32
- promptGuidelines: [
33
- "Use codesearch for API usage, code examples, and implementation-oriented documentation.",
34
- "Use websearch for broad discovery and webfetch for a known URL.",
35
- "Use a separate research workflow instead of codesearch when several searches, fetches, and synthesis are needed.",
36
- ],
30
+ "Search Exa for API usage, code examples, and implementation-oriented documentation context. Use websearch for broad discovery and webfetch for a known URL. Use a separate research workflow when several searches, fetches, and synthesis are needed. Output is truncated to 2,000 lines or 50 KB.",
37
31
  parameters: codeSearchParams,
38
32
  async execute(_toolCallId, params, signal, onUpdate) {
39
33
  const timeout = normalizeTimeout(params.timeout, 25);
@@ -66,13 +66,7 @@ export function createWebFetchTool(rowState: ToolRowStateStore) {
66
66
  name: "webfetch",
67
67
  label: "Web Fetch",
68
68
  description:
69
- "Fetch a known HTTP(S) URL as Markdown, text, or HTML. Supports inline images, limits response bodies to 5 MB, and truncates text to 2,000 lines or 50 KB.",
70
- promptSnippet: "Fetch a specific URL and extract readable content",
71
- promptGuidelines: [
72
- "Use webfetch when you already have a URL and need its content.",
73
- "Use websearch for broad discovery and codesearch for implementation-oriented lookups.",
74
- "Use a separate research workflow instead of webfetch when several searches, fetches, and synthesis are needed.",
75
- ],
69
+ "Fetch a known HTTP(S) URL as Markdown, text, or HTML. Use webfetch when you already have a URL; use websearch for broad discovery and codesearch for implementation-oriented lookups. Use a separate research workflow when several searches, fetches, and synthesis are needed. Supports inline images, limits response bodies to 5 MB, and truncates text to 2,000 lines or 50 KB.",
76
70
  parameters: webFetchParams,
77
71
  async execute(_toolCallId, params, signal, onUpdate) {
78
72
  let url: URL;
@@ -36,13 +36,7 @@ export function createWebSearchTool(rowState: ToolRowStateStore) {
36
36
  name: "websearch",
37
37
  label: "Web Search",
38
38
  description:
39
- "Search the public web through Exa for current information and relevant pages. Output is truncated to 2,000 lines or 50 KB.",
40
- promptSnippet: "Search the public web for current or external information",
41
- promptGuidelines: [
42
- "Use websearch for broad discovery, then webfetch when you have a specific URL.",
43
- "Use codesearch for implementation-oriented code and documentation context.",
44
- "Use a separate research workflow instead of websearch when several searches, fetches, and synthesis are needed.",
45
- ],
39
+ "Search the public web through Exa for current information and relevant pages. Use websearch for broad discovery, then webfetch for a known URL; use codesearch for implementation-oriented code and documentation context. Use a separate research workflow when several searches, fetches, and synthesis are needed. Output is truncated to 2,000 lines or 50 KB.",
46
40
  parameters: webSearchParams,
47
41
  async execute(_toolCallId, params, signal, onUpdate) {
48
42
  const timeout = normalizeTimeout(params.timeout, 25);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.7.2",
3
+ "version": "0.8.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.7.2",
31
+ "@shanepadgett/tau-tui": "0.8.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { Api, AssistantMessage, Message, Model, ThinkingLevel, Tool } from "@earendil-works/pi-ai";
2
3
  import { completeSimple } from "@earendil-works/pi-ai/compat";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -13,7 +14,6 @@ const SEVEN_DAYS_MS = 604_800_000;
13
14
  interface GenerationContext {
14
15
  ui: ExtensionContext["ui"];
15
16
  signal: AbortSignal | undefined;
16
- sessionManager?: { getSessionId(): string };
17
17
  }
18
18
 
19
19
  interface ModelFallbackOptions {
@@ -131,9 +131,10 @@ async function requestValidated<T>(
131
131
  ): Promise<T> {
132
132
  const userMessage: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() };
133
133
  const messages: Message[] = [userMessage];
134
+ const sessionId = randomUUID();
134
135
 
135
136
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
136
- const response = await completeCandidate(ctx, candidate, messages);
137
+ const response = await completeCandidate(ctx, candidate, messages, sessionId);
137
138
  const text = responseText(response);
138
139
  if (response.stopReason === "error") {
139
140
  const error = new Error(response.errorMessage || "model returned an error");
@@ -169,9 +170,10 @@ async function requestToolValidated<T>(
169
170
  maxAttempts = MAX_TOOL_ATTEMPTS,
170
171
  ): Promise<T> {
171
172
  const messages: Message[] = [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }];
173
+ const sessionId = randomUUID();
172
174
 
173
175
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
174
- const response = await completeCandidate(ctx, candidate, messages, [tool]);
176
+ const response = await completeCandidate(ctx, candidate, messages, sessionId, [tool]);
175
177
  const text = responseText(response);
176
178
  const toolCalls = response.content.flatMap((part) => (part.type === "toolCall" ? [part] : []));
177
179
  const output = text || formatToolCalls(toolCalls);
@@ -206,6 +208,7 @@ function completeCandidate(
206
208
  ctx: GenerationContext,
207
209
  candidate: ModelCandidate,
208
210
  messages: readonly Message[],
211
+ sessionId: string,
209
212
  tools?: Tool[],
210
213
  ): Promise<AssistantMessage> {
211
214
  return completeSimple(candidate.model, tools ? { messages: [...messages], tools } : { messages: [...messages] }, {
@@ -213,7 +216,7 @@ function completeCandidate(
213
216
  headers: candidate.headers,
214
217
  signal: ctx.signal,
215
218
  reasoning: candidate.reasoning,
216
- sessionId: ctx.sessionManager?.getSessionId(),
219
+ sessionId,
217
220
  });
218
221
  }
219
222
 
@@ -0,0 +1,40 @@
1
+ import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ interface TauSystemPromptContribution {
4
+ id: string;
5
+ order: number;
6
+ render: (event: BeforeAgentStartEvent, ctx: ExtensionContext) => string | undefined | Promise<string | undefined>;
7
+ }
8
+
9
+ interface Registration extends TauSystemPromptContribution {
10
+ token: symbol;
11
+ }
12
+
13
+ const registrations = new Map<string, Registration>();
14
+
15
+ export function registerTauSystemPromptContribution(contribution: TauSystemPromptContribution): () => void {
16
+ const token = Symbol(contribution.id);
17
+ registrations.set(contribution.id, { ...contribution, token });
18
+ return () => {
19
+ if (registrations.get(contribution.id)?.token === token) registrations.delete(contribution.id);
20
+ };
21
+ }
22
+
23
+ export async function collectTauSystemPromptContributions(
24
+ event: BeforeAgentStartEvent,
25
+ ctx: ExtensionContext,
26
+ ): Promise<string[]> {
27
+ const ordered = [...registrations.values()].sort(
28
+ (left, right) => left.order - right.order || left.id.localeCompare(right.id),
29
+ );
30
+ const rendered = await Promise.all(ordered.map((contribution) => contribution.render(event, ctx)));
31
+ const seen = new Set<string>();
32
+ const blocks: string[] = [];
33
+ for (const value of rendered) {
34
+ const block = value?.trim();
35
+ if (!block || seen.has(block)) continue;
36
+ seen.add(block);
37
+ blocks.push(block);
38
+ }
39
+ return blocks;
40
+ }