@shanepadgett/tau-agent 0.20.2 → 0.22.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,10 @@ 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 ? ["Prune now."] : parsed.tier > 1 ? ["Prune soon."] : []),
132
+ ],
79
133
  });
80
134
  }
81
135
 
@@ -108,7 +162,7 @@ export function renderContextPruneResult(
108
162
  lastComponent: unknown,
109
163
  ): Text {
110
164
  const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
111
- const details = parseContextPruneDetailsV1(result.details);
165
+ const details = parseContextPruneDetailsV2(result.details);
112
166
  if (!details) {
113
167
  component.setText(
114
168
  theme.fg(
@@ -118,27 +172,21 @@ export function renderContextPruneResult(
118
172
  );
119
173
  return component;
120
174
  }
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;
175
+ const pruned = details.prunedToolCallIds.length + details.prunedAutoreadRowIds.length;
129
176
  const retained = details.retainedToolCallIds.length + details.retainedAutoreadRowIds.length;
130
177
  let text = theme.fg(
131
- "success",
132
- `Pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}`,
178
+ details.warnings.length === 0 ? "success" : "warning",
179
+ `Checkpoint · pruned ${pruned} · retained ${retained} · refreshed ${details.refreshedFiles.length} · deferred ${details.deferredFiles.length}${details.warnings.length === 0 ? "" : ` · warnings ${details.warnings.length}`}`,
133
180
  );
134
181
  if (expanded) {
135
182
  const lines = [
136
- ...details.newlyPrunedToolCallIds.map((id) => `pruned tool: ${id}`),
137
- ...details.newlyPrunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
183
+ ...details.prunedToolCallIds.map((id) => `pruned tool: ${id}`),
184
+ ...details.prunedAutoreadRowIds.map((id) => `pruned autoread: ${id}`),
138
185
  ...details.retainedToolCallIds.map((id) => `retained tool: ${id}`),
139
186
  ...details.retainedAutoreadRowIds.map((id) => `retained autoread: ${id}`),
140
187
  ...details.refreshedFiles.map((file) => `refreshed: ${file.path} (${file.rowId})`),
141
188
  ...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
189
+ ...details.warnings.map((warning) => `warning: ${warning}`),
142
190
  ];
143
191
  const shown = lines.slice(0, MAX_EXPANDED_ITEMS).map((line) => boundedText(line, MAX_EXPANDED_LINE_CHARACTERS));
144
192
  if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more`);
@@ -174,6 +222,18 @@ function isBoundary(value: unknown): value is number {
174
222
  return isPercent(value) && value > 0;
175
223
  }
176
224
 
225
+ function isReminder(value: unknown): value is number {
226
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
227
+ }
228
+
229
+ function isTier(value: unknown): value is number {
230
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
231
+ }
232
+
233
+ function isTierFloor(value: unknown): value is number {
234
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 5;
235
+ }
236
+
177
237
  function isRecord(value: unknown): value is Record<string, unknown> {
178
238
  return typeof value === "object" && value !== null && !Array.isArray(value);
179
239
  }
@@ -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,145 @@
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(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages;
39
+ if (messages.length === 0) {
40
+ ctx.ui.notify("No conversation context to hand off.", "error");
41
+ return;
42
+ }
43
+
44
+ let generationError: string | undefined;
45
+ const draft = await ctx.ui.custom<HandoffDraft | null>((tui, theme, _keys, done) => {
46
+ const loader = new BorderedLoader(tui, theme, "Generating handoff");
47
+ loader.onAbort = () => done(null);
48
+ void (async () => {
49
+ try {
50
+ const thinking = pi.getThinkingLevel();
51
+ const candidates = await resolveCandidates(
52
+ ctx,
53
+ [
54
+ {
55
+ provider: model.provider,
56
+ model: model.id,
57
+ reasoning: thinking === "off" ? undefined : thinking,
58
+ },
59
+ ],
60
+ false,
61
+ );
62
+ const conversation = serializeConversation(convertToLlm(messages));
63
+ const result = await generateToolValidated(
64
+ { ui: ctx.ui, signal: loader.signal },
65
+ candidates,
66
+ buildHandoffRequest(conversation, goal, ctx.cwd),
67
+ HANDOFF_TOOL,
68
+ handoffDraftFromToolInput,
69
+ (error, output) =>
70
+ [
71
+ `The handoff failed validation: ${error.message}`,
72
+ `Call ${HANDOFF_TOOL.name} again with corrected arguments only.`,
73
+ "Previous response:",
74
+ output,
75
+ ].join("\n"),
76
+ { maxAttempts: 3 },
77
+ );
78
+ done(result);
79
+ } catch (error) {
80
+ if (!loader.signal.aborted) generationError = errorText(error);
81
+ done(null);
82
+ }
83
+ })();
84
+ return loader;
85
+ });
86
+
87
+ if (!draft) {
88
+ ctx.ui.notify(
89
+ generationError ? `Handoff failed: ${generationError}` : "Handoff cancelled.",
90
+ generationError ? "error" : "info",
91
+ );
92
+ return;
93
+ }
94
+
95
+ const batchId = randomUUID();
96
+ const cwd = ctx.cwd;
97
+ const result = await ctx.newSession({
98
+ parentSession: currentSessionFile,
99
+ setup: async (sessionManager) => {
100
+ const messages = await Promise.all(
101
+ draft.files.map(async (path, index) => {
102
+ const details = {
103
+ rowId: `${batchId}:${index}`,
104
+ path,
105
+ cwd,
106
+ source: "handoff",
107
+ batchId,
108
+ };
109
+ try {
110
+ return await prepareAutoreadMessage({
111
+ ...details,
112
+ signal: undefined,
113
+ isLifecycleCurrent: () => true,
114
+ });
115
+ } catch (error) {
116
+ const message = errorText(error);
117
+ return {
118
+ customType: "tau.autoread" as const,
119
+ content: `${path}\nAutoread failed: ${message}`,
120
+ display: true as const,
121
+ details: { ...details, status: "failed", error: message } satisfies AutoreadDetails,
122
+ };
123
+ }
124
+ }),
125
+ );
126
+ for (const message of messages) {
127
+ sessionManager.appendCustomMessageEntry(
128
+ message.customType,
129
+ message.content,
130
+ message.display,
131
+ message.details,
132
+ );
133
+ }
134
+ },
135
+ withSession: async (replacementCtx) => {
136
+ replacementCtx.ui.setEditorText(draft.prompt);
137
+ replacementCtx.ui.notify("Handoff ready. Review the draft and submit when ready.", "info");
138
+ },
139
+ });
140
+ if (result.cancelled) {
141
+ ctx.ui.notify("New session cancelled.", "info");
142
+ }
143
+ },
144
+ });
145
+ }
@@ -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
+ }