@shanepadgett/tau-agent 0.33.1 → 0.35.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.
Files changed (203) hide show
  1. package/docs/context.md +29 -20
  2. package/docs/extending-tau-agent.md +63 -24
  3. package/extensions/appshot/index.ts +112 -111
  4. package/extensions/aside/README.md +7 -0
  5. package/extensions/aside/index.ts +168 -0
  6. package/extensions/aside/panel.ts +83 -0
  7. package/extensions/bash-approval/README.md +20 -0
  8. package/extensions/bash-approval/index.ts +235 -0
  9. package/extensions/bash-approval/settings.ts +24 -0
  10. package/extensions/branch/index.ts +38 -21
  11. package/extensions/cache-diagnostics/index.ts +70 -58
  12. package/extensions/checkpoint/README.md +9 -0
  13. package/extensions/checkpoint/checkpoint-budget.ts +66 -0
  14. package/extensions/checkpoint/checkpoint.ts +279 -0
  15. package/extensions/checkpoint/index.ts +102 -0
  16. package/extensions/checkpoint/messages.ts +170 -0
  17. package/extensions/checkpoint/prompt.ts +24 -0
  18. package/extensions/checkpoint/settings.ts +27 -0
  19. package/extensions/commit/commit-effort.ts +3 -3
  20. package/extensions/commit/commit-plan.ts +2 -2
  21. package/extensions/context/README.md +12 -8
  22. package/extensions/context/definitions.ts +178 -55
  23. package/extensions/context/index.ts +192 -160
  24. package/extensions/context/panel.ts +57 -37
  25. package/extensions/context/sync.ts +139 -89
  26. package/extensions/context/validation.ts +37 -25
  27. package/extensions/effort/README.md +1 -1
  28. package/extensions/effort/index.ts +3 -3
  29. package/extensions/effort/state.ts +3 -18
  30. package/extensions/explore/guidance.ts +6 -47
  31. package/extensions/explore/index.ts +38 -53
  32. package/extensions/explore/{ast/read → read}/hook.ts +12 -7
  33. package/extensions/explore/{ast/tools → tools}/ast-search.ts +15 -19
  34. package/extensions/explore/{ast/tools → tools}/context.ts +16 -23
  35. package/extensions/explore/{ast/tools → tools}/deps.ts +11 -15
  36. package/extensions/explore/{ast/tools → tools}/discover.ts +15 -17
  37. package/extensions/explore/{ast/tools → tools}/impact.ts +20 -23
  38. package/extensions/explore/{ast/tools → tools}/outline.ts +21 -24
  39. package/extensions/explore/{ast/tools → tools}/relationships.ts +23 -32
  40. package/extensions/explore/{ast/tools → tools}/render.ts +1 -1
  41. package/extensions/explore/{ast/tools → tools}/reverse-deps.ts +11 -15
  42. package/extensions/explore/{ast/tools → tools}/show.ts +16 -19
  43. package/extensions/explore/tools/target-params.ts +9 -0
  44. package/extensions/footer/index.ts +75 -56
  45. package/extensions/handoff/index.ts +8 -30
  46. package/extensions/image-gen/index.ts +46 -40
  47. package/extensions/patch/executor.ts +190 -135
  48. package/extensions/patch/matcher.ts +82 -48
  49. package/extensions/patch/parser.ts +149 -105
  50. package/extensions/patch/render.ts +32 -39
  51. package/extensions/patch/summary.ts +30 -26
  52. package/extensions/ready/README.md +7 -0
  53. package/extensions/ready/index.ts +58 -0
  54. package/extensions/ready/model.ts +128 -0
  55. package/extensions/ready/packs.ts +272 -0
  56. package/extensions/ready/render.ts +445 -0
  57. package/extensions/ready/scan.ts +760 -0
  58. package/extensions/reference/index.ts +2 -0
  59. package/extensions/reference/panel.ts +94 -31
  60. package/extensions/review/README.md +14 -6
  61. package/extensions/review/index.ts +58 -99
  62. package/extensions/review/model.ts +13 -33
  63. package/extensions/review/session.ts +9 -23
  64. package/extensions/runtime-context/context.ts +30 -26
  65. package/extensions/script-runner/README.md +2 -2
  66. package/extensions/script-runner/index.ts +122 -82
  67. package/extensions/silent-command-runner/index.ts +38 -23
  68. package/extensions/soul/README.md +2 -2
  69. package/extensions/soul/index.ts +4 -4
  70. package/extensions/soul/prompt.ts +6 -10
  71. package/extensions/soul/settings.ts +6 -3
  72. package/extensions/subagent/agents/context-sync.md +185 -33
  73. package/extensions/subagent/agents/scout.md +2 -2
  74. package/extensions/subagent/agents/web-research.md +2 -2
  75. package/extensions/subagent/agents.ts +137 -84
  76. package/extensions/subagent/cmux-dashboard.ts +39 -30
  77. package/extensions/subagent/index.ts +79 -59
  78. package/extensions/subagent/resume.ts +1 -1
  79. package/extensions/subagent/run.ts +308 -193
  80. package/extensions/subagent/runtime.ts +781 -343
  81. package/extensions/tau/index.ts +47 -38
  82. package/extensions/tau-help/help.md +20 -8
  83. package/extensions/tool-loader/README.md +4 -2
  84. package/extensions/tool-loader/index.ts +77 -52
  85. package/extensions/web/index.ts +6 -3
  86. package/extensions/web/webfetch.ts +114 -86
  87. package/package.json +3 -3
  88. package/schemas/tau.schema.json +38 -35
  89. package/shared/bounded-text-result.ts +1 -0
  90. package/shared/checkpoint-visibility.ts +9 -0
  91. package/shared/events.ts +28 -10
  92. package/shared/isolated-session.ts +72 -30
  93. package/shared/model-effort.ts +23 -22
  94. package/shared/model-fallback/index.ts +66 -41
  95. package/shared/settings/load.ts +45 -38
  96. package/shared/temporary-output-store.ts +16 -10
  97. package/{extensions/explore → src}/ast/adapter.ts +11 -2
  98. package/{extensions/explore → src}/ast/engine.ts +54 -38
  99. package/{extensions/explore → src}/ast/format/ast-search.ts +15 -6
  100. package/{extensions/explore → src}/ast/format/context.ts +3 -6
  101. package/src/ast/format/deps.ts +94 -0
  102. package/{extensions/explore → src}/ast/format/discover.ts +2 -2
  103. package/{extensions/explore → src}/ast/format/impact.ts +9 -11
  104. package/{extensions/explore → src}/ast/format/outline.ts +2 -2
  105. package/{extensions/explore → src}/ast/format/relationships.ts +11 -7
  106. package/{extensions/explore → src}/ast/format/show.ts +1 -1
  107. package/src/ast/format/targeted-result.ts +20 -0
  108. package/src/ast/grammars/c_sharp.wasm +0 -0
  109. package/{extensions/explore → src}/ast/grammars/manifest.json +9 -12
  110. package/{extensions/explore → src}/ast/grammars/manifest.ts +0 -4
  111. package/{extensions/explore → src}/ast/graph/file-graph.ts +210 -98
  112. package/{extensions/explore → src}/ast/graph/relationships.ts +285 -72
  113. package/{extensions/explore → src}/ast/ir.ts +6 -0
  114. package/src/ast/languages/csharp-file-deps.ts +90 -0
  115. package/{extensions/explore → src}/ast/languages/csharp.ts +9 -40
  116. package/src/ast/languages/dotted-index.ts +131 -0
  117. package/{extensions/explore → src}/ast/languages/file-dep-util.ts +22 -13
  118. package/src/ast/languages/fixtures/sample.cs +53 -0
  119. package/src/ast/languages/fixtures/sample.cs.expected.json +156 -0
  120. package/src/ast/languages/fixtures/sample.go +37 -0
  121. package/src/ast/languages/fixtures/sample.go.expected.json +96 -0
  122. package/src/ast/languages/fixtures/sample.java +40 -0
  123. package/src/ast/languages/fixtures/sample.java.expected.json +126 -0
  124. package/src/ast/languages/fixtures/sample.kt +31 -0
  125. package/src/ast/languages/fixtures/sample.kt.expected.json +134 -0
  126. package/src/ast/languages/fixtures/sample.md +23 -0
  127. package/src/ast/languages/fixtures/sample.md.expected.json +44 -0
  128. package/src/ast/languages/fixtures/sample.rs +54 -0
  129. package/src/ast/languages/fixtures/sample.rs.expected.json +178 -0
  130. package/src/ast/languages/fixtures/sample.swift +45 -0
  131. package/src/ast/languages/fixtures/sample.swift.expected.json +147 -0
  132. package/src/ast/languages/fixtures/sample.ts +45 -0
  133. package/src/ast/languages/fixtures/sample.ts.expected.json +141 -0
  134. package/src/ast/languages/fixtures/sample.tsx +36 -0
  135. package/src/ast/languages/fixtures/sample.tsx.expected.json +59 -0
  136. package/{extensions/explore → src}/ast/languages/go-file-deps.ts +6 -0
  137. package/{extensions/explore → src}/ast/languages/go.ts +21 -3
  138. package/{extensions/explore → src}/ast/languages/java.ts +5 -38
  139. package/src/ast/languages/jvm-file-deps.ts +135 -0
  140. package/{extensions/explore → src}/ast/languages/kotlin.ts +5 -39
  141. package/{extensions/explore → src}/ast/languages/rust-file-deps.ts +3 -3
  142. package/{extensions/explore → src}/ast/languages/rust.ts +21 -13
  143. package/{extensions/explore → src}/ast/languages/swift-file-deps.ts +22 -15
  144. package/{extensions/explore → src}/ast/languages/swift.ts +9 -39
  145. package/{extensions/explore → src}/ast/languages/tree.ts +47 -0
  146. package/{extensions/explore → src}/ast/languages/typescript-file-deps.ts +108 -0
  147. package/{extensions/explore → src}/ast/languages/typescript-package-surface.ts +4 -3
  148. package/{extensions/explore → src}/ast/languages/typescript.ts +3 -5
  149. package/{extensions/explore → src}/ast/markdown.ts +72 -47
  150. package/{extensions/explore → src}/ast/queries/ast-search.ts +164 -146
  151. package/{extensions/explore → src}/ast/queries/composite-target.ts +1 -1
  152. package/{extensions/explore → src}/ast/queries/context.ts +116 -80
  153. package/{extensions/explore → src}/ast/queries/discover.ts +154 -105
  154. package/src/ast/queries/impact.ts +291 -0
  155. package/{extensions/explore → src}/ast/queries/outline.ts +4 -2
  156. package/{extensions/explore → src}/ast/queries/show.ts +46 -42
  157. package/{extensions/explore → src}/ast/query.ts +1 -1
  158. package/{extensions/explore → src}/ast/registry.ts +1 -2
  159. package/{extensions/explore → src}/ast/scan.ts +1 -1
  160. package/src/ast/session.ts +50 -0
  161. package/{extensions/explore → src}/ast/slice.ts +8 -0
  162. package/{extensions/explore → src/ast}/traverse.ts +32 -20
  163. package/src/file-injection/index.ts +547 -0
  164. package/src/image-generation/client.ts +50 -31
  165. package/src/image-generation/index.ts +177 -134
  166. package/src/index.ts +2 -0
  167. package/src/tool-loading/index.ts +58 -0
  168. package/extensions/context/evidence.ts +0 -517
  169. package/extensions/context/projection.ts +0 -141
  170. package/extensions/context/state.ts +0 -30
  171. package/extensions/context/write-scope.ts +0 -109
  172. package/extensions/explore/ast/format/deps.ts +0 -86
  173. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  174. package/extensions/explore/ast/languages/csharp-file-deps.ts +0 -189
  175. package/extensions/explore/ast/languages/fixtures/sample.cs +0 -17
  176. package/extensions/explore/ast/languages/fixtures/sample.java +0 -21
  177. package/extensions/explore/ast/languages/fixtures/sample.kt +0 -15
  178. package/extensions/explore/ast/languages/fixtures/sample.odin +0 -28
  179. package/extensions/explore/ast/languages/fixtures/sample.rs +0 -31
  180. package/extensions/explore/ast/languages/fixtures/sample.swift +0 -25
  181. package/extensions/explore/ast/languages/jvm-file-deps.ts +0 -217
  182. package/extensions/explore/ast/languages/odin-file-deps.ts +0 -175
  183. package/extensions/explore/ast/languages/odin.ts +0 -407
  184. package/extensions/explore/ast/queries/impact.ts +0 -250
  185. package/extensions/explore/outline-injection.ts +0 -151
  186. package/extensions/explore/read/autoread.ts +0 -118
  187. package/extensions/review/panel.ts +0 -128
  188. package/extensions/working-memory/README.md +0 -17
  189. package/extensions/working-memory/checkpoint.ts +0 -242
  190. package/extensions/working-memory/index.ts +0 -341
  191. package/extensions/working-memory/memory.ts +0 -235
  192. package/extensions/working-memory/render.ts +0 -185
  193. package/extensions/working-memory/settings.ts +0 -38
  194. package/extensions/working-memory/state.ts +0 -158
  195. package/shared/autoread.ts +0 -234
  196. package/shared/context-messages.ts +0 -19
  197. package/shared/outline-injection.ts +0 -56
  198. /package/{extensions/explore → src}/ast/format/composite.ts +0 -0
  199. /package/{extensions/explore → src}/ast/grammars/kotlin.wasm +0 -0
  200. /package/{extensions/explore → src}/ast/grammars/swift.wasm +0 -0
  201. /package/{extensions/explore → src}/ast/identity.ts +0 -0
  202. /package/{extensions/explore → src}/ast/package-surface.ts +0 -0
  203. /package/{extensions/explore/ast/read/policy.ts → src/ast/read-policy.ts} +0 -0
@@ -0,0 +1,279 @@
1
+ import {
2
+ defineTool,
3
+ keyText,
4
+ type ExtensionAPI,
5
+ type SessionEntry,
6
+ type SessionMessageEntry,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { truncateToWidth, type Component, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { Type, type Static } from "typebox";
11
+ import { prepareFileInjection } from "@shanepadgett/tau-agent";
12
+ import { areCheckpointRowsVisible } from "../../shared/checkpoint-visibility.ts";
13
+ import type { FileInjectionFile } from "../../src/file-injection/index.ts";
14
+ import { extractConversationText } from "./messages.ts";
15
+ import { formatCheckpointMessage } from "./prompt.ts";
16
+
17
+ export const CHECKPOINT_TOOL = "checkpoint";
18
+ const CONTINUATION_TYPE = "tau.checkpoint";
19
+ const CONTINUATION_PREAMBLE =
20
+ "Continue directly from the checkpoint state and provided files. Trust the provided sources and continue the listed work.";
21
+ const CHECKPOINT_PREVIEW_CHARACTERS = 240;
22
+ const CHECKPOINT_RENDER_CHARACTERS = 24_000;
23
+ const CHECKPOINT_RENDER_LINES = 200;
24
+ const CONVERSATION_MESSAGE_ID_ATTRIBUTE = /^(<(?:user|assistant)-message)\s+id="(?:\\.|[^"\\])*">$/gm;
25
+
26
+ const lineRange = Type.Object(
27
+ {
28
+ startLine: Type.Integer({ minimum: 1 }),
29
+ endLine: Type.Integer({ minimum: 1 }),
30
+ },
31
+ { additionalProperties: false },
32
+ );
33
+
34
+ const checkpointFile = Type.Union([
35
+ Type.Object(
36
+ {
37
+ path: Type.String({ minLength: 1 }),
38
+ mode: Type.Literal("read"),
39
+ ranges: Type.Optional(Type.Array(lineRange)),
40
+ },
41
+ { additionalProperties: false },
42
+ ),
43
+ Type.Object(
44
+ {
45
+ path: Type.String({ minLength: 1 }),
46
+ mode: Type.Literal("outline"),
47
+ },
48
+ { additionalProperties: false },
49
+ ),
50
+ Type.Object(
51
+ {
52
+ path: Type.String({ minLength: 1 }),
53
+ mode: Type.Literal("deferred"),
54
+ when: Type.String({ minLength: 1 }),
55
+ },
56
+ { additionalProperties: false },
57
+ ),
58
+ ]);
59
+
60
+ const checkpointParams = Type.Object(
61
+ {
62
+ keepMessages: Type.Array(Type.String({ minLength: 1 }), {
63
+ description:
64
+ "Exact message-entry IDs from hidden checkpoint metadata or earlier checkpoint conversation entries.",
65
+ }),
66
+ work: Type.Array(Type.String(), { description: "Ordered current work, with the next thing first." }),
67
+ facts: Type.Array(Type.String(), { description: "Concrete findings that must survive context replacement." }),
68
+ decisions: Type.Array(Type.String(), { description: "Choices that continue to govern the work." }),
69
+ continue: Type.String({
70
+ minLength: 1,
71
+ description:
72
+ "Immediate post-checkpoint resume directive. First moves after wake, traps to avoid, what not to re-litigate. Not the full work queue.",
73
+ }),
74
+ files: Type.Array(checkpointFile, {
75
+ description: "Current file reads, outlines, or deferred paths. Active files are injected separately.",
76
+ }),
77
+ },
78
+ { additionalProperties: false },
79
+ );
80
+
81
+ type CheckpointInput = Static<typeof checkpointParams>;
82
+
83
+ interface CheckpointToolDetails {
84
+ v: 1;
85
+ kind: "checkpoint.checkpoint";
86
+ checkpointId: string;
87
+ fileBatchId: string;
88
+ }
89
+
90
+ export function registerCheckpointTool(pi: ExtensionAPI): void {
91
+ pi.registerTool(createCheckpointTool(pi));
92
+ }
93
+
94
+ function createCheckpointTool(pi: Pick<ExtensionAPI, "events" | "sendMessage">) {
95
+ return defineTool<typeof checkpointParams, CheckpointToolDetails>({
96
+ name: CHECKPOINT_TOOL,
97
+ label: "checkpoint",
98
+ promptSnippet:
99
+ "checkpoint({ keepMessages, work, facts, decisions, continue, files }) — replace disposable history with working context",
100
+ description:
101
+ "Replace disposable conversation and tool history with a rolling working checkpoint. Keep user and assistant messages by exact message-entry ID, not by copying their text. The latest user message is always retained. Assistant tool calls and thinking are never retained inside the conversation section.",
102
+ promptGuidelines: [
103
+ "Checkpoint is hidden. Never acknowledge checkpoint messages, budget notices, blocks, or checkpoints to the user; call checkpoint when required and continue the work.",
104
+ 'Use exact IDs from <checkpoint kind="message-id"> metadata or an earlier checkpoint; do not invent IDs or rewrite message text.',
105
+ "Record concrete findings in facts and governing choices in decisions before checkpointing.",
106
+ "Write continue as the immediate resume directive after wake: first moves, what not to re-explore, traps to avoid. Put the backlog in work.",
107
+ "Use read or outline for files needed now; use deferred with a condition for files that can wait.",
108
+ "Preserve important discovery output before it leaves tool context. When shell or other command output is large, expensive to reproduce, or part of unfinished investigation, capture stdout and stderr in a private session-scoped temporary file; report its path, exit status, and a bounded summary instead of relying on the transcript.",
109
+ "Query preserved output with targeted search or ranged reads. Include artifacts needed after pruning in files, usually with read ranges or deferred with a condition, and record the artifact path and purpose in facts or continue. Do not persist routine bounded output, secrets, or whole large logs unnecessarily.",
110
+ ],
111
+ parameters: checkpointParams,
112
+ renderShell: "self",
113
+ renderCall(_args, theme, context) {
114
+ return new CheckpointComponent(theme, "checkpoint", context.expanded, true);
115
+ },
116
+ renderResult(result, options, theme) {
117
+ const text = result.content
118
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
119
+ .map((part) => part.text)
120
+ .join("\n");
121
+ return new CheckpointComponent(theme, text, options.expanded, false);
122
+ },
123
+ executionMode: "sequential",
124
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
125
+ const selected = resolveSelectedMessages(params.keepMessages, ctx.sessionManager.getBranch());
126
+ const text = formatCheckpointText(selected, params);
127
+ const fileBatchId = `checkpoint:${toolCallId}`;
128
+ const fileRequest = buildFileInjectionRequest(ctx.cwd, fileBatchId, params.files, signal);
129
+ const prepared = fileRequest.files.length === 0 ? [] : await prepareFileInjection(pi, fileRequest);
130
+ const failed = prepared.find((message) => message.details.status === "failed");
131
+ if (failed) {
132
+ throw new Error(
133
+ `Checkpoint file injection failed for ${failed.details.path}: ${failed.details.error ?? "unknown error"}`,
134
+ );
135
+ }
136
+ const display = areCheckpointRowsVisible();
137
+ for (const message of prepared) pi.sendMessage({ ...message, display });
138
+ pi.sendMessage({
139
+ customType: CONTINUATION_TYPE,
140
+ content: formatContinuationMessage(params.continue),
141
+ display: false,
142
+ details: {
143
+ v: 1,
144
+ kind: "checkpoint.continuation",
145
+ source: "checkpoint",
146
+ batchId: fileBatchId,
147
+ },
148
+ });
149
+ return {
150
+ content: [{ type: "text", text }],
151
+ details: { v: 1, kind: "checkpoint.checkpoint", checkpointId: toolCallId, fileBatchId },
152
+ };
153
+ },
154
+ });
155
+ }
156
+
157
+ class CheckpointComponent implements Component {
158
+ private readonly theme: Theme;
159
+ private readonly content: string;
160
+ private readonly expanded: boolean;
161
+ private readonly call: boolean;
162
+
163
+ constructor(theme: Theme, content: string, expanded: boolean, call: boolean) {
164
+ this.theme = theme;
165
+ this.content = content;
166
+ this.expanded = expanded;
167
+ this.call = call;
168
+ }
169
+
170
+ render(width: number): string[] {
171
+ if (!areCheckpointRowsVisible()) return [];
172
+ if (this.call) return [truncateToWidth(this.theme.fg("toolTitle", this.theme.bold(CHECKPOINT_TOOL)), width, "…")];
173
+ const displayContent = this.content.replace(CONVERSATION_MESSAGE_ID_ATTRIBUTE, "$1>");
174
+ if (!this.expanded) {
175
+ const preview = displayContent.replace(/\s+/g, " ").trim().slice(0, CHECKPOINT_PREVIEW_CHARACTERS);
176
+ const suffix = displayContent.length > CHECKPOINT_PREVIEW_CHARACTERS ? "…" : "";
177
+ return [
178
+ truncateToWidth(
179
+ `${this.theme.fg("dim", preview + suffix)} ${this.theme.fg("muted", `(${keyText("app.tools.expand")} to expand)`)}`,
180
+ width,
181
+ "…",
182
+ ),
183
+ ];
184
+ }
185
+
186
+ const bounded =
187
+ displayContent.length > CHECKPOINT_RENDER_CHARACTERS
188
+ ? `${displayContent.slice(0, CHECKPOINT_RENDER_CHARACTERS)}\n…`
189
+ : displayContent;
190
+ return wrapTextWithAnsi(this.theme.fg("dim", bounded), Math.max(1, width))
191
+ .slice(0, CHECKPOINT_RENDER_LINES)
192
+ .map((line) => truncateToWidth(line, width, "…"));
193
+ }
194
+
195
+ invalidate(): void {}
196
+ }
197
+
198
+ function resolveSelectedMessages(ids: readonly string[], branch: readonly SessionEntry[]): SessionMessageEntry[] {
199
+ const entriesById = new Map(branch.map((entry) => [entry.id, entry]));
200
+ const selectedIds = new Set(ids);
201
+ const latestUser = [...branch].reverse().find(isUserMessageEntry);
202
+ if (!latestUser) throw new Error("Checkpoint requires a current user message");
203
+ selectedIds.add(latestUser.id);
204
+
205
+ for (const id of selectedIds) {
206
+ const entry = entriesById.get(id);
207
+ if (!entry || !isConversationEntry(entry)) throw new Error(`Unknown user or assistant message ID: ${id}`);
208
+ if (entry.message.role === "assistant" && extractConversationText(entry.message) === "") {
209
+ throw new Error(`Assistant message ID has no text content: ${id}`);
210
+ }
211
+ }
212
+
213
+ return branch.filter(
214
+ (entry): entry is SessionMessageEntry => selectedIds.has(entry.id) && isConversationEntry(entry),
215
+ );
216
+ }
217
+
218
+ function formatCheckpointText(selected: readonly SessionMessageEntry[], params: CheckpointInput): string {
219
+ const conversation = selected
220
+ .map((entry) => {
221
+ const text = extractConversationText(entry.message);
222
+ if (text === undefined) throw new Error(`Message ID is not a user or assistant message: ${entry.id}`);
223
+ const role = entry.message.role === "user" ? "user" : "assistant";
224
+ return `<${role}-message id=${JSON.stringify(entry.id)}>\n${text}\n</${role}-message>`;
225
+ })
226
+ .join("\n\n");
227
+ const deferred = params.files
228
+ .filter(
229
+ (file): file is Extract<CheckpointInput["files"][number], { mode: "deferred" }> => file.mode === "deferred",
230
+ )
231
+ .map((file) => `- ${file.path} — ${file.when}`);
232
+
233
+ return [
234
+ `Conversation:\n${conversation || "(none)"}`,
235
+ `Work:\n${formatList(params.work)}`,
236
+ `Facts:\n${formatList(params.facts)}`,
237
+ `Decisions:\n${formatList(params.decisions)}`,
238
+ `Continue:\n${params.continue}`,
239
+ `Deferred files:\n${deferred.length ? deferred.join("\n") : "(none)"}`,
240
+ ].join("\n\n");
241
+ }
242
+
243
+ function formatContinuationMessage(continueNudge: string): string {
244
+ return formatCheckpointMessage("continuation", `${CONTINUATION_PREAMBLE}\n\n${continueNudge}`);
245
+ }
246
+
247
+ function buildFileInjectionRequest(
248
+ cwd: string,
249
+ batchId: string,
250
+ files: CheckpointInput["files"],
251
+ signal: AbortSignal | undefined,
252
+ ) {
253
+ const activeFiles: FileInjectionFile[] = [];
254
+ for (const file of files) {
255
+ if (file.mode === "deferred") continue;
256
+ if (file.mode === "outline") {
257
+ activeFiles.push({ path: file.path, mode: "outline" });
258
+ continue;
259
+ }
260
+ activeFiles.push({
261
+ path: file.path,
262
+ mode: "full",
263
+ ...(file.ranges === undefined ? {} : { ranges: file.ranges }),
264
+ });
265
+ }
266
+ return { cwd, source: "checkpoint", batchId, files: activeFiles, signal };
267
+ }
268
+
269
+ function formatList(items: readonly string[]): string {
270
+ return items.length ? items.map((item) => `- ${item}`).join("\n") : "(none)";
271
+ }
272
+
273
+ function isConversationEntry(entry: SessionEntry): entry is SessionMessageEntry {
274
+ return entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant");
275
+ }
276
+
277
+ function isUserMessageEntry(entry: SessionEntry): entry is SessionMessageEntry {
278
+ return entry.type === "message" && entry.message.role === "user";
279
+ }
@@ -0,0 +1,102 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
3
+ import { setCheckpointRowsVisible } from "../../shared/checkpoint-visibility.ts";
4
+ import { CHECKPOINT_TOOL, registerCheckpointTool } from "./checkpoint.ts";
5
+ import { createCheckpointBudget, type CheckpointBudgetNoticeLevel } from "./checkpoint-budget.ts";
6
+ import { projectContextMessages } from "./messages.ts";
7
+ import { CHECKPOINT_SYSTEM_GUIDANCE, formatCheckpointMessage } from "./prompt.ts";
8
+ import checkpointSettings from "./settings.ts";
9
+
10
+ export default function checkpointExtension(pi: ExtensionAPI): void {
11
+ const budget = createCheckpointBudget();
12
+ let checkpointSucceeded = false;
13
+
14
+ const resetBudget = (): void => {
15
+ checkpointSucceeded = false;
16
+ budget.reset();
17
+ };
18
+
19
+ registerCheckpointTool(pi);
20
+ pi.on("session_start", async (_event, ctx) => {
21
+ const settings = await loadTauExtensionSettings(ctx, checkpointSettings);
22
+ budget.configure(settings.checkpointTokenLimit);
23
+ setCheckpointRowsVisible(settings.showToolRows);
24
+ });
25
+ pi.on("session_shutdown", () => {
26
+ setCheckpointRowsVisible(false);
27
+ resetBudget();
28
+ });
29
+ pi.on("session_compact", resetBudget);
30
+ pi.on("session_tree", resetBudget);
31
+ pi.on("session_before_fork", resetBudget);
32
+ pi.on("session_before_switch", resetBudget);
33
+ pi.on("before_agent_start", (event) => ({
34
+ systemPrompt: `${event.systemPrompt}\n\n${CHECKPOINT_SYSTEM_GUIDANCE}`,
35
+ }));
36
+ pi.on("turn_start", (_event, ctx) => {
37
+ if (checkpointSucceeded) resetBudget();
38
+ const noticeLevel = budget.beginTurn(ctx.getContextUsage()?.tokens ?? null);
39
+ if (noticeLevel !== undefined) sendCheckpointBudgetNotice(pi, noticeLevel);
40
+ });
41
+ pi.on("turn_end", (_event, ctx) => {
42
+ if (checkpointSucceeded) {
43
+ resetBudget();
44
+ return;
45
+ }
46
+ const noticeLevel = budget.finishTurn(ctx.getContextUsage()?.tokens ?? null);
47
+ if (noticeLevel !== undefined) sendCheckpointBudgetNotice(pi, noticeLevel);
48
+ });
49
+ pi.on("tool_result", (event) => {
50
+ if (event.toolName === CHECKPOINT_TOOL && !event.isError) checkpointSucceeded = true;
51
+ });
52
+ pi.on("tool_call", (event) => {
53
+ if (checkpointSucceeded || !budget.shouldBlockTool(event.toolName, CHECKPOINT_TOOL)) return;
54
+ return {
55
+ block: true,
56
+ reason: formatCheckpointMessage(
57
+ "block",
58
+ "Checkpoint required before using other tools. Call checkpoint, then continue the user's work.",
59
+ ),
60
+ };
61
+ });
62
+ pi.on("context", (event, ctx) => ({
63
+ messages: projectContextMessages(event.messages, ctx.sessionManager.buildContextEntries()),
64
+ }));
65
+ }
66
+
67
+ function sendCheckpointBudgetNotice(pi: ExtensionAPI, level: CheckpointBudgetNoticeLevel): void {
68
+ pi.sendMessage({
69
+ customType: "tau.checkpoint",
70
+ content: checkpointBudgetMessage(level),
71
+ display: false,
72
+ details: {
73
+ v: 1,
74
+ kind: "checkpoint.checkpoint-budget",
75
+ source: "checkpoint",
76
+ level,
77
+ },
78
+ });
79
+ }
80
+
81
+ function checkpointBudgetMessage(level: CheckpointBudgetNoticeLevel): string {
82
+ switch (level) {
83
+ case 50:
84
+ return formatCheckpointMessage(
85
+ "budget",
86
+ "Context usage reached 50% of the configured checkpoint limit. Continue current work and checkpoint when convenient.",
87
+ { level: "50" },
88
+ );
89
+ case 75:
90
+ return formatCheckpointMessage(
91
+ "budget",
92
+ "Context usage reached 75% of the configured checkpoint limit. Run checkpoint soon. At 100%, non-checkpoint tool calls will be blocked until checkpoint succeeds.",
93
+ { level: "75" },
94
+ );
95
+ case 100:
96
+ return formatCheckpointMessage(
97
+ "budget",
98
+ "Checkpoint budget reached. Call checkpoint now before using any other tool. Do not call other tools.",
99
+ { level: "100" },
100
+ );
101
+ }
102
+ }
@@ -0,0 +1,170 @@
1
+ import type { AssistantMessage, TextContent, UserMessage } from "@earendil-works/pi-ai";
2
+ import { sessionEntryToContextMessages, type ContextEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { formatCheckpointMessage } from "./prompt.ts";
4
+
5
+ type AgentMessage = ContextEvent["messages"][number];
6
+ type ContextPair = { entry: SessionEntry; message: AgentMessage };
7
+
8
+ const CHECKPOINT_KIND = "checkpoint.checkpoint";
9
+ const MESSAGE_ID_METADATA_TYPE = "tau.checkpoint.message-id";
10
+
11
+ type CheckpointMetadataMessage = Extract<AgentMessage, { role: "custom" }>;
12
+
13
+ type CheckpointAnchor = {
14
+ call: ContextPair;
15
+ result: ContextPair;
16
+ resultIndex: number;
17
+ toolCallIds: Set<string>;
18
+ };
19
+
20
+ function pairsAlignWithMessages(pairs: readonly ContextPair[], messages: readonly AgentMessage[]): boolean {
21
+ if (pairs.length !== messages.length) return false;
22
+ return !pairs.some((pair, index) => {
23
+ const expected = messageKey(pair.message);
24
+ const actual = messageKey(messages[index]);
25
+ return expected === undefined || actual === undefined || expected !== actual;
26
+ });
27
+ }
28
+
29
+ function findCheckpointResult(
30
+ pairs: readonly ContextPair[],
31
+ ): { result: ContextPair; resultIndex: number; checkpointId: string; fileBatchId: string } | undefined {
32
+ for (let resultIndex = pairs.length - 1; resultIndex >= 0; resultIndex--) {
33
+ const result = pairs[resultIndex];
34
+ if (result.entry.type !== "message" || result.message.role !== "toolResult") continue;
35
+ const details = checkpointDetails(result.message.details);
36
+ if (details === undefined) continue;
37
+ return { result, resultIndex, checkpointId: details.checkpointId, fileBatchId: details.fileBatchId };
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function resolveCheckpointCall(
43
+ pairs: readonly ContextPair[],
44
+ resultIndex: number,
45
+ checkpointId: string,
46
+ ): { call: ContextPair; toolCallIds: Set<string> } | "misaligned" {
47
+ let callIndex = resultIndex - 1;
48
+ while (callIndex >= 0 && !hasCheckpointToolCall(pairs[callIndex].message, checkpointId)) callIndex--;
49
+ if (callIndex < 0) return "misaligned";
50
+ const call = pairs[callIndex];
51
+ if (call.message.role !== "assistant") return "misaligned";
52
+ const toolCallIds = new Set<string>();
53
+ for (const part of call.message.content) {
54
+ if (part.type === "toolCall") toolCallIds.add(part.id);
55
+ }
56
+ return { call, toolCallIds };
57
+ }
58
+
59
+ function findCheckpointAnchor(pairs: readonly ContextPair[]): CheckpointAnchor | "misaligned" | undefined {
60
+ const found = findCheckpointResult(pairs);
61
+ if (found === undefined) return undefined;
62
+ const resolved = resolveCheckpointCall(pairs, found.resultIndex, found.checkpointId);
63
+ if (resolved === "misaligned") return "misaligned";
64
+ return {
65
+ call: resolved.call,
66
+ result: found.result,
67
+ resultIndex: found.resultIndex,
68
+ toolCallIds: resolved.toolCallIds,
69
+ };
70
+ }
71
+
72
+ function keepCheckpointPair(
73
+ checkpoint: CheckpointAnchor | undefined,
74
+ entry: SessionEntry,
75
+ message: AgentMessage,
76
+ index: number,
77
+ ): boolean {
78
+ if (checkpoint === undefined) return true;
79
+ if (index > checkpoint.resultIndex) return true;
80
+ if (entry.id === checkpoint.call.entry.id || entry.id === checkpoint.result.entry.id) return true;
81
+ return message.role === "toolResult" && checkpoint.toolCallIds.has(message.toolCallId);
82
+ }
83
+
84
+ function expandPairMessages(entry: SessionEntry, message: AgentMessage): AgentMessage[] {
85
+ if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
86
+ if (message.role === "assistant" && extractConversationText(message) === "") return [message];
87
+ return [createMessageIdMetadata(entry.id, message), message];
88
+ }
89
+ return [message];
90
+ }
91
+
92
+ /** Prunes completed checkpoint history and adds hidden provider-context message IDs. */
93
+ export function projectContextMessages(
94
+ messages: readonly AgentMessage[],
95
+ entries: readonly SessionEntry[],
96
+ ): AgentMessage[] {
97
+ const pairs: ContextPair[] = [];
98
+ for (const entry of entries) {
99
+ for (const message of sessionEntryToContextMessages(entry)) pairs.push({ entry, message });
100
+ }
101
+
102
+ const checkpoint = findCheckpointAnchor(pairs);
103
+ if (checkpoint === "misaligned") return [...messages];
104
+ if (checkpoint === undefined && !pairsAlignWithMessages(pairs, messages)) return [...messages];
105
+
106
+ return pairs
107
+ .filter(({ entry, message }, index) => keepCheckpointPair(checkpoint, entry, message, index))
108
+ .flatMap(({ entry, message }) => expandPairMessages(entry, message));
109
+ }
110
+
111
+ /** Extracts only the original text from a user or assistant message. */
112
+ export function extractConversationText(message: AgentMessage): string | undefined {
113
+ if (message.role === "user") return userText(message);
114
+ if (message.role === "assistant") return assistantText(message);
115
+ return undefined;
116
+ }
117
+
118
+ function messageKey(message: AgentMessage): string | undefined {
119
+ return JSON.stringify(message, (key, value) => (key === "timestamp" ? undefined : value));
120
+ }
121
+
122
+ function checkpointDetails(details: unknown): { checkpointId: string; fileBatchId: string } | undefined {
123
+ if (typeof details !== "object" || details === null) return undefined;
124
+ const value = details as Record<string, unknown>;
125
+ if (
126
+ value.v !== 1 ||
127
+ value.kind !== CHECKPOINT_KIND ||
128
+ typeof value.checkpointId !== "string" ||
129
+ typeof value.fileBatchId !== "string"
130
+ )
131
+ return undefined;
132
+ return { checkpointId: value.checkpointId, fileBatchId: value.fileBatchId };
133
+ }
134
+
135
+ function hasCheckpointToolCall(message: AgentMessage, checkpointId: string): boolean {
136
+ return (
137
+ message.role === "assistant" &&
138
+ message.content.some((part) => part.type === "toolCall" && part.name === "checkpoint" && part.id === checkpointId)
139
+ );
140
+ }
141
+
142
+ function createMessageIdMetadata(id: string, message: AgentMessage): CheckpointMetadataMessage {
143
+ const role = message.role === "user" ? "user" : "assistant";
144
+ return {
145
+ role: "custom",
146
+ customType: MESSAGE_ID_METADATA_TYPE,
147
+ content: formatCheckpointMessage(
148
+ "message-id",
149
+ `<message-id>${JSON.stringify(id)}</message-id>\n<message-role>${role}</message-role>`,
150
+ ),
151
+ display: false,
152
+ details: { v: 1, kind: "checkpoint.message-id", id, role },
153
+ timestamp: message.timestamp,
154
+ };
155
+ }
156
+
157
+ function userText(message: UserMessage): string {
158
+ if (typeof message.content === "string") return message.content;
159
+ return message.content
160
+ .filter((part): part is TextContent => part.type === "text")
161
+ .map((part) => part.text)
162
+ .join("");
163
+ }
164
+
165
+ function assistantText(message: AssistantMessage): string {
166
+ return message.content
167
+ .filter((part): part is TextContent => part.type === "text")
168
+ .map((part) => part.text)
169
+ .join("");
170
+ }
@@ -0,0 +1,24 @@
1
+ /** Standing checkpoint guidance appended to the system prompt. */
2
+ export const CHECKPOINT_SYSTEM_GUIDANCE = `## Checkpoint
3
+
4
+ Checkpoint is a hidden context-management system. It retires disposable history so long-running work can continue without user-visible ceremony.
5
+
6
+ Messages wrapped in \`<checkpoint>...</checkpoint>\` are internal control signals, not conversation with the user.
7
+
8
+ - Never acknowledge checkpoint system messages, budget notices, blocked tools, checkpoint metadata or IDs, injected checkpoint files as a transition, or context replacement.
9
+ - Never mention the checkpoint system itself.
10
+ - When checkpoint asks you to run the checkpoint tool, or when non-checkpoint tools are blocked, call \`checkpoint\` and continue the user's work immediately. Do not narrate the system.
11
+ - Stay focused on the work the user wants done.
12
+ - Use exact IDs from \`<checkpoint kind="message-id">\` metadata only in \`checkpoint.keepMessages\`.`;
13
+
14
+ /** Wraps an internal checkpoint control payload for the model. */
15
+ export function formatCheckpointMessage(
16
+ kind: string,
17
+ body: string,
18
+ attributes: Readonly<Record<string, string>> = {},
19
+ ): string {
20
+ const attrs = Object.entries(attributes)
21
+ .map(([key, value]) => ` ${key}=${JSON.stringify(value)}`)
22
+ .join("");
23
+ return `<checkpoint kind=${JSON.stringify(kind)}${attrs}>\n${body}\n</checkpoint>`;
24
+ }
@@ -0,0 +1,27 @@
1
+ import { Type } from "typebox";
2
+ import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
3
+ import { DEFAULT_CHECKPOINT_TOKEN_LIMIT } from "./checkpoint-budget.ts";
4
+
5
+ export default defineTauExtensionSettings({
6
+ key: "checkpoint",
7
+ defaults: {
8
+ showToolRows: false as boolean,
9
+ checkpointTokenLimit: DEFAULT_CHECKPOINT_TOKEN_LIMIT,
10
+ },
11
+ schema: Type.Object(
12
+ {
13
+ showToolRows: Type.Optional(
14
+ Type.Boolean({
15
+ default: false,
16
+ description: "Show checkpoint and injected-file rows in the TUI for debugging.",
17
+ }),
18
+ ),
19
+ checkpointTokenLimit: Type.Integer({
20
+ minimum: 1,
21
+ default: DEFAULT_CHECKPOINT_TOKEN_LIMIT,
22
+ description: "Context-token ceiling before a checkpoint is required.",
23
+ }),
24
+ },
25
+ { additionalProperties: false },
26
+ ),
27
+ });
@@ -6,9 +6,9 @@ const LOW_EFFORT_MAX_CHANGED_LINES = 500;
6
6
  export function commitEffort(files: readonly Pick<DirtyFile, "changeSize">[]): ModelEffort {
7
7
  let changedLines = 0;
8
8
  for (const file of files) {
9
- if (file.changeSize === "binary") return "medium";
9
+ if (file.changeSize === "binary") return "standard";
10
10
  changedLines += file.changeSize;
11
- if (changedLines > LOW_EFFORT_MAX_CHANGED_LINES) return "medium";
11
+ if (changedLines > LOW_EFFORT_MAX_CHANGED_LINES) return "standard";
12
12
  }
13
- return "low";
13
+ return "quick";
14
14
  }
@@ -54,7 +54,7 @@ export async function generatePlan(
54
54
  const prompt = buildPlanPrompt(evidence, previousPlan, regenerationNote);
55
55
  return generateToolValidated(
56
56
  ctx,
57
- await resolveEffortCandidates(ctx, commitEffort(evidence.files), true),
57
+ await resolveEffortCandidates(ctx, commitEffort(evidence.files), { includeParentModel: true }),
58
58
  prompt,
59
59
  COMMIT_PLAN_TOOL,
60
60
  (input) => commitGroupsFromToolInput(input, evidence.files),
@@ -84,7 +84,7 @@ export async function regenerateMessage(
84
84
  const prompt = buildMessagePrompt(evidence, selected, previousPlan, selectedGroupId, regenerationNote);
85
85
  return generateValidated(
86
86
  ctx,
87
- await resolveEffortCandidates(ctx, commitEffort(selected), true),
87
+ await resolveEffortCandidates(ctx, commitEffort(selected), { includeParentModel: true }),
88
88
  prompt,
89
89
  requireCommitMessage,
90
90
  undefined,