@shanepadgett/tau-agent 0.20.1 → 0.21.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.
@@ -1,7 +1,7 @@
1
1
  import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { Marker } from "@shanepadgett/tau-tui";
4
- import { parseContextPruneDetailsV1 } from "../../shared/context-pruning-state.ts";
4
+ import { parseContextPruneDetailsV2 } from "../../shared/context-pruning-state.ts";
5
5
  import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.ts";
6
6
  import type { ContextPruneInput } from "./prune.ts";
7
7
 
@@ -10,22 +10,48 @@ const MAX_EXPANDED_LINE_CHARACTERS = 240;
10
10
  const MAX_EXPANDED_TEXT_CHARACTERS = 3_000;
11
11
  const MAX_WARNING_CHARACTERS = 1_000;
12
12
 
13
- export interface ContextPruningNudgeDetailsV1 {
14
- v: 1;
15
- kind: "automatic" | "manual";
16
- percent: number | null;
17
- boundary: number | null;
18
- pressure: boolean;
19
- anchorToolCallId: string | null;
20
- growthBaselinePercent: number | null;
21
- }
13
+ export type ContextPruningNudgeDetailsV2 =
14
+ | {
15
+ v: 2;
16
+ kind: "automatic";
17
+ percent: number;
18
+ boundary: number;
19
+ reminder: number;
20
+ tier: number;
21
+ tierCount: number;
22
+ tierFloor: number;
23
+ anchorToolCallId: string | null;
24
+ growthBaselinePercent: number;
25
+ }
26
+ | {
27
+ v: 2;
28
+ kind: "manual";
29
+ percent: null;
30
+ boundary: null;
31
+ reminder: null;
32
+ tier: null;
33
+ tierCount: null;
34
+ tierFloor: null;
35
+ anchorToolCallId: string | null;
36
+ growthBaselinePercent: null;
37
+ };
22
38
 
23
- export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPruningNudgeDetailsV1 | undefined {
39
+ export function parseContextPruningNudgeDetailsV2(value: unknown): ContextPruningNudgeDetailsV2 | undefined {
24
40
  if (!isRecord(value)) return undefined;
25
- const keys = ["v", "kind", "percent", "boundary", "pressure", "anchorToolCallId", "growthBaselinePercent"];
41
+ const keys = [
42
+ "v",
43
+ "kind",
44
+ "percent",
45
+ "boundary",
46
+ "reminder",
47
+ "tier",
48
+ "tierCount",
49
+ "tierFloor",
50
+ "anchorToolCallId",
51
+ "growthBaselinePercent",
52
+ ];
26
53
  if (Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) return undefined;
27
- if (value.v !== 1 || (value.kind !== "automatic" && value.kind !== "manual")) return undefined;
28
- if (typeof value.pressure !== "boolean") return undefined;
54
+ if (value.v !== 2 || (value.kind !== "automatic" && value.kind !== "manual")) return undefined;
29
55
  if (
30
56
  value.anchorToolCallId !== null &&
31
57
  (typeof value.anchorToolCallId !== "string" || value.anchorToolCallId.length === 0)
@@ -33,14 +59,25 @@ export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPrunin
33
59
  return undefined;
34
60
  }
35
61
  if (value.kind === "manual") {
36
- if (value.percent !== null || value.boundary !== null || value.growthBaselinePercent !== null || value.pressure)
62
+ if (
63
+ value.percent !== null ||
64
+ value.boundary !== null ||
65
+ value.reminder !== null ||
66
+ value.tier !== null ||
67
+ value.tierCount !== null ||
68
+ value.tierFloor !== null ||
69
+ value.growthBaselinePercent !== null
70
+ )
37
71
  return undefined;
38
72
  return {
39
- v: 1,
73
+ v: 2,
40
74
  kind: "manual",
41
75
  percent: null,
42
76
  boundary: null,
43
- pressure: false,
77
+ reminder: null,
78
+ tier: null,
79
+ tierCount: null,
80
+ tierFloor: null,
44
81
  anchorToolCallId: value.anchorToolCallId,
45
82
  growthBaselinePercent: null,
46
83
  };
@@ -48,25 +85,39 @@ export function parseContextPruningNudgeDetailsV1(value: unknown): ContextPrunin
48
85
  if (
49
86
  !isPercent(value.percent) ||
50
87
  !isBoundary(value.boundary) ||
88
+ !isReminder(value.reminder) ||
89
+ !isTier(value.tier) ||
90
+ !isTier(value.tierCount) ||
91
+ !isTierFloor(value.tierFloor) ||
51
92
  !isPercent(value.growthBaselinePercent) ||
52
93
  value.boundary > value.percent ||
53
- value.growthBaselinePercent > value.percent
94
+ value.boundary <= value.growthBaselinePercent ||
95
+ value.tier > value.tierCount ||
96
+ value.tierFloor > value.tierCount ||
97
+ value.tier !== Math.max(Math.min(value.reminder, value.tierCount), value.tierFloor) ||
98
+ (value.anchorToolCallId === null && value.growthBaselinePercent !== 0)
54
99
  ) {
55
100
  return undefined;
56
101
  }
102
+ const interval = (value.boundary - value.growthBaselinePercent) / value.reminder;
103
+ if (!Number.isInteger(interval) || interval < 1 || interval > 100 || value.percent - value.boundary >= interval)
104
+ return undefined;
57
105
  return {
58
- v: 1,
106
+ v: 2,
59
107
  kind: "automatic",
60
108
  percent: value.percent,
61
109
  boundary: value.boundary,
62
- pressure: value.pressure,
110
+ reminder: value.reminder,
111
+ tier: value.tier,
112
+ tierCount: value.tierCount,
113
+ tierFloor: value.tierFloor,
63
114
  anchorToolCallId: value.anchorToolCallId,
64
115
  growthBaselinePercent: value.growthBaselinePercent,
65
116
  };
66
117
  }
67
118
 
68
119
  export function renderContextPruningNudge(details: unknown, theme: Theme): Marker | undefined {
69
- const parsed = parseContextPruningNudgeDetailsV1(details);
120
+ const parsed = parseContextPruningNudgeDetailsV2(details);
70
121
  if (!parsed) return undefined;
71
122
  return new Marker({
72
123
  theme,
@@ -75,7 +126,14 @@ export function renderContextPruningNudge(details: unknown, theme: Theme): Marke
75
126
  parts:
76
127
  parsed.kind === "manual"
77
128
  ? ["Prune requested."]
78
- : [`${parsed.percent}%`, ...(parsed.pressure ? ["Prune suggested."] : [])],
129
+ : [
130
+ `${parsed.percent}%`,
131
+ ...(parsed.tier === parsed.tierCount
132
+ ? ["Prune now."]
133
+ : parsed.tier > 1
134
+ ? ["Prune soon."]
135
+ : []),
136
+ ],
79
137
  });
80
138
  }
81
139
 
@@ -108,7 +166,7 @@ export function renderContextPruneResult(
108
166
  lastComponent: unknown,
109
167
  ): Text {
110
168
  const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
111
- const details = parseContextPruneDetailsV1(result.details);
169
+ const details = parseContextPruneDetailsV2(result.details);
112
170
  if (!details) {
113
171
  component.setText(
114
172
  theme.fg(
@@ -118,27 +176,21 @@ export function renderContextPruneResult(
118
176
  );
119
177
  return component;
120
178
  }
121
- if (details.status === "skipped") {
122
- component.setText(
123
- theme.fg("warning", boundedText(firstResultText(result) || "Prune skipped", MAX_WARNING_CHARACTERS)),
124
- );
125
- return component;
126
- }
127
-
128
- const pruned = details.newlyPrunedToolCallIds.length + details.newlyPrunedAutoreadRowIds.length;
179
+ const pruned = details.prunedToolCallIds.length + details.prunedAutoreadRowIds.length;
129
180
  const retained = details.retainedToolCallIds.length + details.retainedAutoreadRowIds.length;
130
181
  let text = theme.fg(
131
- "success",
132
- `Pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}`,
182
+ details.warnings.length === 0 ? "success" : "warning",
183
+ `Checkpoint · pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}${details.warnings.length === 0 ? "" : ` · warnings ${details.warnings.length}`}`,
133
184
  );
134
185
  if (expanded) {
135
186
  const lines = [
136
- ...details.newlyPrunedToolCallIds.map((id) => `pruned tool: ${id}`),
137
- ...details.newlyPrunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
187
+ ...details.prunedToolCallIds.map((id) => `pruned tool: ${id}`),
188
+ ...details.prunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
138
189
  ...details.retainedToolCallIds.map((id) => `retained tool: ${id}`),
139
190
  ...details.retainedAutoreadRowIds.map((id) => `retained autoread: ${id}`),
140
191
  ...details.refreshedFiles.map((file) => `refreshed: ${file.path} (${file.rowId})`),
141
192
  ...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
193
+ ...details.warnings.map((warning) => `warning: ${warning}`),
142
194
  ];
143
195
  const shown = lines.slice(0, MAX_EXPANDED_ITEMS).map((line) => boundedText(line, MAX_EXPANDED_LINE_CHARACTERS));
144
196
  if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more`);
@@ -174,6 +226,18 @@ function isBoundary(value: unknown): value is number {
174
226
  return isPercent(value) && value > 0;
175
227
  }
176
228
 
229
+ function isReminder(value: unknown): value is number {
230
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
231
+ }
232
+
233
+ function isTier(value: unknown): value is number {
234
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
235
+ }
236
+
237
+ function isTierFloor(value: unknown): value is number {
238
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 5;
239
+ }
240
+
177
241
  function isRecord(value: unknown): value is Record<string, unknown> {
178
242
  return typeof value === "object" && value !== null && !Array.isArray(value);
179
243
  }
@@ -1,13 +1,18 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
3
3
 
4
+ const DEFAULT_NUDGE_INSTRUCTIONS: [string, ...string[]] = [
5
+ "No prune is required yet unless broad exploration has converged or substantial evidence is already irrelevant. Continue coherent work.",
6
+ "Move toward a pruning point now. Finish the current coherent step, then prune before starting another broad exploration. Managed context is materially increasing model cost.",
7
+ "Prune now before further tool work. Continuing with stale managed context is wasting money.",
8
+ ];
9
+
4
10
  export default defineTauExtensionSettings({
5
11
  key: "contextPruning",
6
12
  defaults: {
7
13
  enabled: true as boolean,
8
14
  nudgeEveryPercent: 20 as number,
9
- pressurePercent: 50 as number,
10
- minimumReclaimTokens: 8000 as number,
15
+ nudgeInstructions: DEFAULT_NUDGE_INSTRUCTIONS,
11
16
  },
12
17
  schema: Type.Object(
13
18
  {
@@ -20,19 +25,13 @@ export default defineTauExtensionSettings({
20
25
  description: "Context growth interval between automatic pruning hints.",
21
26
  }),
22
27
  ),
23
- pressurePercent: Type.Optional(
24
- Type.Integer({
25
- default: 50,
26
- minimum: 1,
27
- maximum: 99,
28
- description: "Context usage above which pruning hints become urgent.",
29
- }),
30
- ),
31
- minimumReclaimTokens: Type.Optional(
32
- Type.Integer({
33
- default: 8000,
34
- minimum: 1,
35
- description: "Minimum estimated tokens a prune must reclaim.",
28
+ nudgeInstructions: Type.Optional(
29
+ Type.Array(Type.String({ minLength: 1, maxLength: 2_000 }), {
30
+ default: DEFAULT_NUDGE_INSTRUCTIONS,
31
+ minItems: 1,
32
+ maxItems: 5,
33
+ description:
34
+ "Ordered automatic pruning instructions. Later reminders repeat the final instruction, and the final tier requires an anchor before further tool work.",
36
35
  }),
37
36
  ),
38
37
  },
@@ -67,6 +67,7 @@ export function createReadCacheStore(): ReadCacheStore {
67
67
  }
68
68
  const pruning = replayContextPruningState(manager.getBranch());
69
69
  const ignoredRowIds = new Set([...pruning.prunedToolCallIds, ...pruning.prunedAutoreadRowIds]);
70
+ for (const id of pruning.retainedToolCallIds) ignoredRowIds.delete(id);
70
71
  const state = replayReadCache(manager.buildContextEntries(), ctx.cwd, ignoredRowIds);
71
72
  const trust = state.scopeTrust.get(pathKey)?.get(scopeKey);
72
73
  return {
@@ -127,17 +128,21 @@ export function replayReadCache(
127
128
  if (entry.type === "message" && "message" in entry) message = entry.message;
128
129
  else if (entry.type === "custom_message") message = entry;
129
130
  else continue;
130
- const parsedMeta = readMetaFromMessage(message);
131
- if (parsedMeta) {
132
- const rowId = readRowId(message);
133
- if (rowId && ignoredRowIds.has(rowId)) continue;
131
+ const candidates = carriedFileMessages(message);
132
+ let acceptedCarryForward = false;
133
+ for (const candidate of candidates ?? [message]) {
134
+ const parsedMeta = readMetaFromMessage(candidate);
135
+ if (!parsedMeta) continue;
136
+ acceptedCarryForward = true;
137
+ const candidateRowId = readRowId(candidate);
138
+ if (candidateRowId && ignoredRowIds.has(candidateRowId)) continue;
134
139
  const pathKey = resolve(cwd, parsedMeta.pathKey);
135
140
  const meta = pathKey === parsedMeta.pathKey ? parsedMeta : { ...parsedMeta, pathKey };
136
- if (rowId && applyReadMeta(trust, failedPatchRecoveryPaths, message, meta, rowId)) {
141
+ if (candidateRowId && applyReadMeta(trust, failedPatchRecoveryPaths, candidate, meta, candidateRowId)) {
137
142
  const acceptedTrust = trust.get(meta.pathKey)?.get(meta.scopeKey);
138
143
  if (acceptedTrust) {
139
144
  acceptedRows.push({
140
- rowId,
145
+ rowId: candidateRowId,
141
146
  pathKey: meta.pathKey,
142
147
  scopeKey: meta.scopeKey,
143
148
  meta,
@@ -145,8 +150,8 @@ export function replayReadCache(
145
150
  });
146
151
  }
147
152
  }
148
- continue;
149
153
  }
154
+ if (acceptedCarryForward) continue;
150
155
  for (const path of failedPatchPaths(message, cwd)) failedPatchRecoveryPaths.add(path);
151
156
  }
152
157
  const completeFileChains = new Map<string, CompleteFileDependencyChain>();
@@ -163,6 +168,25 @@ export function replayReadCache(
163
168
  return { scopeTrust: trust, acceptedRows, completeFileChains, failedPatchRecoveryPaths };
164
169
  }
165
170
 
171
+ function carriedFileMessages(message: unknown): unknown[] | undefined {
172
+ if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== "context_prune") return undefined;
173
+ if (!isRecord(message.details) || message.details.v !== 2 || !Array.isArray(message.details.refreshedFiles)) {
174
+ return undefined;
175
+ }
176
+ if (!Array.isArray(message.content)) {
177
+ return undefined;
178
+ }
179
+ const messages: unknown[] = [];
180
+ for (let index = 0; index < message.details.refreshedFiles.length; index += 1) {
181
+ const file = message.details.refreshedFiles[index];
182
+ const part = message.content[index + 1];
183
+ if (!isRecord(file) || !isRecord(file.autoreadDetails)) continue;
184
+ if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") continue;
185
+ messages.push({ customType: "tau.autoread", content: part.text, details: file.autoreadDetails });
186
+ }
187
+ return messages;
188
+ }
189
+
166
190
  function applyReadMeta(
167
191
  trust: Map<string, Map<string, ReadCacheScopeTrust>>,
168
192
  failedPatchRecoveryPaths: Set<string>,
@@ -0,0 +1,7 @@
1
+ # Handoff
2
+
3
+ Start a fresh chat from an idea already understood in the current conversation.
4
+
5
+ Run `/handoff <goal for the new chat>`. Tau generates a focused opening prompt, starts a new linked session, and asks Tau autoread to preload the relevant project files selected from the current context. The prompt stays in the editor so you can review or change it before submitting.
6
+
7
+ The original session remains available through Pi's session history. Press Escape while Tau generates the handoff to cancel.
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ BorderedLoader,
4
+ buildSessionContext,
5
+ convertToLlm,
6
+ type ExtensionAPI,
7
+ serializeConversation,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { prepareAutoreadMessage, type AutoreadDetails } from "../explore/autoread.ts";
10
+ import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
11
+ import { errorText } from "../../shared/text.ts";
12
+ import { buildHandoffRequest, type HandoffDraft, handoffDraftFromToolInput, HANDOFF_TOOL } from "./model.ts";
13
+
14
+ export default function handoffExtension(pi: ExtensionAPI): void {
15
+ pi.registerCommand("handoff", {
16
+ description: "Start a fresh chat with a generated prompt and autoread files",
17
+ handler: async (args, ctx) => {
18
+ if (ctx.mode !== "tui") {
19
+ ctx.ui.notify("/handoff requires TUI mode.", "error");
20
+ return;
21
+ }
22
+ const goal = args.trim();
23
+ if (!goal) {
24
+ ctx.ui.notify("Usage: /handoff <goal for the new chat>", "error");
25
+ return;
26
+ }
27
+ await ctx.waitForIdle();
28
+ const model = ctx.model;
29
+ if (!model) {
30
+ ctx.ui.notify("No model selected.", "error");
31
+ return;
32
+ }
33
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
34
+ if (!currentSessionFile) {
35
+ ctx.ui.notify("The current session must be persisted before handoff.", "error");
36
+ return;
37
+ }
38
+ const messages = buildSessionContext(
39
+ ctx.sessionManager.getEntries(),
40
+ ctx.sessionManager.getLeafId(),
41
+ ).messages;
42
+ if (messages.length === 0) {
43
+ ctx.ui.notify("No conversation context to hand off.", "error");
44
+ return;
45
+ }
46
+
47
+ let generationError: string | undefined;
48
+ const draft = await ctx.ui.custom<HandoffDraft | null>((tui, theme, _keys, done) => {
49
+ const loader = new BorderedLoader(tui, theme, "Generating handoff");
50
+ loader.onAbort = () => done(null);
51
+ void (async () => {
52
+ try {
53
+ const thinking = pi.getThinkingLevel();
54
+ const candidates = await resolveCandidates(
55
+ ctx,
56
+ [{ provider: model.provider, model: model.id, reasoning: thinking === "off" ? undefined : thinking }],
57
+ false,
58
+ );
59
+ const conversation = serializeConversation(convertToLlm(messages));
60
+ const result = await generateToolValidated(
61
+ { ui: ctx.ui, signal: loader.signal },
62
+ candidates,
63
+ buildHandoffRequest(conversation, goal, ctx.cwd),
64
+ HANDOFF_TOOL,
65
+ handoffDraftFromToolInput,
66
+ (error, output) =>
67
+ [
68
+ `The handoff failed validation: ${error.message}`,
69
+ `Call ${HANDOFF_TOOL.name} again with corrected arguments only.`,
70
+ "Previous response:",
71
+ output,
72
+ ].join("\n"),
73
+ { maxAttempts: 3 },
74
+ );
75
+ done(result);
76
+ } catch (error) {
77
+ if (!loader.signal.aborted) generationError = errorText(error);
78
+ done(null);
79
+ }
80
+ })();
81
+ return loader;
82
+ });
83
+
84
+ if (!draft) {
85
+ ctx.ui.notify(generationError ? `Handoff failed: ${generationError}` : "Handoff cancelled.", generationError ? "error" : "info");
86
+ return;
87
+ }
88
+
89
+ const batchId = randomUUID();
90
+ const cwd = ctx.cwd;
91
+ const result = await ctx.newSession({
92
+ parentSession: currentSessionFile,
93
+ setup: async (sessionManager) => {
94
+ const messages = await Promise.all(
95
+ draft.files.map(async (path, index) => {
96
+ const details = {
97
+ rowId: `${batchId}:${index}`,
98
+ path,
99
+ cwd,
100
+ source: "handoff",
101
+ batchId,
102
+ };
103
+ try {
104
+ return await prepareAutoreadMessage({
105
+ ...details,
106
+ signal: undefined,
107
+ isLifecycleCurrent: () => true,
108
+ });
109
+ } catch (error) {
110
+ const message = errorText(error);
111
+ return {
112
+ customType: "tau.autoread" as const,
113
+ content: `${path}\nAutoread failed: ${message}`,
114
+ display: true as const,
115
+ details: { ...details, status: "failed", error: message } satisfies AutoreadDetails,
116
+ };
117
+ }
118
+ }),
119
+ );
120
+ for (const message of messages) {
121
+ sessionManager.appendCustomMessageEntry(
122
+ message.customType,
123
+ message.content,
124
+ message.display,
125
+ message.details,
126
+ );
127
+ }
128
+ },
129
+ withSession: async (replacementCtx) => {
130
+ replacementCtx.ui.setEditorText(draft.prompt);
131
+ replacementCtx.ui.notify("Handoff ready. Review the draft and submit when ready.", "info");
132
+ },
133
+ });
134
+ if (result.cancelled) {
135
+ ctx.ui.notify("New session cancelled.", "info");
136
+ }
137
+ },
138
+ });
139
+ }
@@ -0,0 +1,71 @@
1
+ import type { Tool } from "@earendil-works/pi-ai";
2
+ import { posix } from "node:path";
3
+ import { Type } from "typebox";
4
+
5
+ export interface HandoffDraft {
6
+ prompt: string;
7
+ files: string[];
8
+ }
9
+
10
+ export const HANDOFF_TOOL = {
11
+ name: "submit_handoff",
12
+ description: "Submit the prompt and project files for the new chat. This is the only allowed response.",
13
+ parameters: Type.Object(
14
+ {
15
+ prompt: Type.String({ description: "Self-contained opening prompt for the new chat" }),
16
+ files: Type.Array(
17
+ Type.String({ description: "Project-relative file path already known from the current conversation" }),
18
+ { description: "Focused files Tau should autoread in the new chat" },
19
+ ),
20
+ },
21
+ { additionalProperties: false },
22
+ ),
23
+ } satisfies Tool;
24
+
25
+ export function buildHandoffRequest(conversation: string, goal: string, cwd: string): string {
26
+ return [
27
+ "You prepare a handoff from the current coding chat into a fresh chat.",
28
+ `Call ${HANDOFF_TOOL.name} exactly once. Write no prose outside the tool call.`,
29
+ "",
30
+ "Build the handoff only from the supplied conversation and goal. Do not research, invent findings, or assume file contents that are absent from the conversation.",
31
+ "The prompt must give the new agent the relevant decisions, progress, constraints, unresolved questions, and exact next task.",
32
+ "Keep it focused. The selected files will be autoread separately, so summarize why they matter without copying their contents into the prompt.",
33
+ `Every file must be a path under ${cwd}, relative to that directory, and must already be known from the conversation. Return only files relevant to the goal.`,
34
+ "Return an empty files array when no known file is clearly relevant.",
35
+ "",
36
+ "## Handoff goal",
37
+ goal,
38
+ "",
39
+ "## Current effective conversation",
40
+ conversation,
41
+ ].join("\n");
42
+ }
43
+
44
+ export function handoffDraftFromToolInput(input: unknown): HandoffDraft {
45
+ if (!input || typeof input !== "object") throw new Error("Handoff output must be an object.");
46
+ const record = input as Record<string, unknown>;
47
+ if (typeof record.prompt !== "string" || !record.prompt.trim()) {
48
+ throw new Error("Handoff prompt must be a non-empty string.");
49
+ }
50
+ if (!Array.isArray(record.files) || !record.files.every((path) => typeof path === "string")) {
51
+ throw new Error("Handoff files must be an array of strings.");
52
+ }
53
+
54
+ const files: string[] = [];
55
+ for (const rawPath of record.files) {
56
+ const path = posix.normalize(rawPath.trim().replace(/^@/, "").replace(/^\.\//, ""));
57
+ if (
58
+ path === "." ||
59
+ path.startsWith("/") ||
60
+ /^[a-z]:\//i.test(path) ||
61
+ path === ".." ||
62
+ path.startsWith("../") ||
63
+ path.includes("\\")
64
+ ) {
65
+ throw new Error(`Handoff file must be a project-relative path: ${rawPath}`);
66
+ }
67
+ if (!files.includes(path)) files.push(path);
68
+ }
69
+
70
+ return { prompt: record.prompt.trim(), files };
71
+ }
@@ -145,7 +145,9 @@ Start a fresh thread with \`agent\` and \`task\`. Continue an existing thread wi
145
145
 
146
146
  Pass \`files\` when exact relevant files are already known. Tau autoreads current line-numbered snapshots into that child turn before it starts.
147
147
 
148
- 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.`;
148
+ 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.
149
+
150
+ Review limit: for one user task or coherent implementation batch, call the \`review\` agent at most twice. The first call is the initial review. The only permitted second call is a follow-up in the same retained thread to check fixes from that initial review. Never call \`review\` a third time for the same task. Do not call it for documentation-only work, trivial edits, small localized changes, or micro-adjustments after review; inspect and test those directly.`;
149
151
  return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
150
152
  });
151
153
  pi.registerTool(
@@ -36,7 +36,7 @@ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, a
36
36
 
37
37
  ## context-pruning
38
38
 
39
- Gives the agent `context_prune` for removing substantial stale tool evidence after broad exploration converges. The agent records durable conclusions before creating an anchor, can retain exact exchanges or current file knowledge, and continues the task automatically. Context markers occasionally steer the agent after tool-heavy growth and become more direct under pressure. Run `/prune` with no arguments to request an anchor manually. Session history remains unchanged, and anchors, markers, and pruned-row state follow the active branch.
39
+ Gives the agent `context_prune` for creating a hard context checkpoint after broad exploration converges. Everything before the checkpoint leaves future model input unless the agent retains an exact tool exchange or carries a file forward as a fresh snapshot. File snapshot failures are reported without blocking the checkpoint. Deferred files remain as a small conditional note. Context markers use an ordered instruction ladder that escalates from informational guidance to pruning before further tool work. Run `/prune` with no arguments to request a checkpoint manually. Session history remains unchanged, and checkpoints, markers, and pruned-row state follow the active branch.
40
40
 
41
41
  ## explore
42
42
 
@@ -46,6 +46,10 @@ Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `fi
46
46
 
47
47
  Adds `/footer` to toggle and refresh Tau’s status footer.
48
48
 
49
+ ## handoff
50
+
51
+ Adds `/handoff <goal>` to start a fresh linked session from the current conversation. Tau generates an opening prompt, autoreads the relevant project files it already knows about, and leaves the prompt in the editor for review instead of submitting it.
52
+
49
53
  ## ideas
50
54
 
51
55
  Adds `/ideas` to log rough ideas or open the ideas browser.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.20.1",
3
+ "version": "0.21.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.20.1",
31
+ "@shanepadgett/tau-tui": "0.21.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },
@@ -68,18 +68,21 @@
68
68
  "maximum": 100,
69
69
  "description": "Context growth interval between automatic pruning hints."
70
70
  },
71
- "pressurePercent": {
72
- "type": "integer",
73
- "default": 50,
74
- "minimum": 1,
75
- "maximum": 99,
76
- "description": "Context usage above which pruning hints become urgent."
77
- },
78
- "minimumReclaimTokens": {
79
- "type": "integer",
80
- "default": 8000,
81
- "minimum": 1,
82
- "description": "Minimum estimated tokens a prune must reclaim."
71
+ "nudgeInstructions": {
72
+ "type": "array",
73
+ "items": {
74
+ "type": "string",
75
+ "minLength": 1,
76
+ "maxLength": 2000
77
+ },
78
+ "default": [
79
+ "No prune is required yet unless broad exploration has converged or substantial evidence is already irrelevant. Continue coherent work.",
80
+ "Move toward a pruning point now. Finish the current coherent step, then prune before starting another broad exploration. Managed context is materially increasing model cost.",
81
+ "Prune now before further tool work. Continuing with stale managed context is wasting money."
82
+ ],
83
+ "minItems": 1,
84
+ "maxItems": 5,
85
+ "description": "Ordered automatic pruning instructions. Later reminders repeat the final instruction, and the final tier requires an anchor before further tool work."
83
86
  }
84
87
  },
85
88
  "additionalProperties": false