pi-agent-flow 2.0.0 → 2.0.2

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 (195) hide show
  1. package/README.md +126 -489
  2. package/agents/audit.md +7 -2
  3. package/agents/build.md +6 -2
  4. package/agents/craft.md +6 -3
  5. package/agents/debug.md +7 -3
  6. package/agents/ideas.md +8 -4
  7. package/agents/scout.md +8 -1
  8. package/dist/batch/apply-patch.d.ts +60 -0
  9. package/dist/batch/apply-patch.d.ts.map +1 -0
  10. package/dist/batch/apply-patch.js +477 -0
  11. package/dist/batch/apply-patch.js.map +1 -0
  12. package/dist/batch/batch-bash.d.ts +0 -6
  13. package/dist/batch/batch-bash.d.ts.map +1 -1
  14. package/dist/batch/batch-bash.js +52 -14
  15. package/dist/batch/batch-bash.js.map +1 -1
  16. package/dist/batch/constants.d.ts +45 -4
  17. package/dist/batch/constants.d.ts.map +1 -1
  18. package/dist/batch/constants.js +72 -4
  19. package/dist/batch/constants.js.map +1 -1
  20. package/dist/batch/execute.d.ts +8 -2
  21. package/dist/batch/execute.d.ts.map +1 -1
  22. package/dist/batch/execute.js +338 -70
  23. package/dist/batch/execute.js.map +1 -1
  24. package/dist/batch/fuzzy-edit.d.ts +4 -1
  25. package/dist/batch/fuzzy-edit.d.ts.map +1 -1
  26. package/dist/batch/fuzzy-edit.js +7 -2
  27. package/dist/batch/fuzzy-edit.js.map +1 -1
  28. package/dist/batch/index.d.ts +3 -15
  29. package/dist/batch/index.d.ts.map +1 -1
  30. package/dist/batch/index.js +48 -78
  31. package/dist/batch/index.js.map +1 -1
  32. package/dist/batch/render.d.ts.map +1 -1
  33. package/dist/batch/render.js +30 -7
  34. package/dist/batch/render.js.map +1 -1
  35. package/dist/batch/shell-compress.d.ts +25 -0
  36. package/dist/batch/shell-compress.d.ts.map +1 -0
  37. package/dist/batch/shell-compress.js +602 -0
  38. package/dist/batch/shell-compress.js.map +1 -0
  39. package/dist/batch/summary.d.ts.map +1 -1
  40. package/dist/batch/summary.js +4 -0
  41. package/dist/batch/summary.js.map +1 -1
  42. package/dist/batch/symbols.d.ts.map +1 -1
  43. package/dist/batch/symbols.js +12 -7
  44. package/dist/batch/symbols.js.map +1 -1
  45. package/dist/config/config.d.ts +5 -0
  46. package/dist/config/config.d.ts.map +1 -1
  47. package/dist/config/config.js +63 -0
  48. package/dist/config/config.js.map +1 -1
  49. package/dist/config/models.d.ts +2 -0
  50. package/dist/config/models.d.ts.map +1 -0
  51. package/dist/config/models.js +49 -0
  52. package/dist/config/models.js.map +1 -0
  53. package/dist/config/settings-resolver.js +2 -2
  54. package/dist/config/settings-resolver.js.map +1 -1
  55. package/dist/core/agents.js +2 -2
  56. package/dist/core/agents.js.map +1 -1
  57. package/dist/core/depth.d.ts +3 -3
  58. package/dist/core/depth.d.ts.map +1 -1
  59. package/dist/core/depth.js +5 -5
  60. package/dist/core/depth.js.map +1 -1
  61. package/dist/core/executor.d.ts +10 -3
  62. package/dist/core/executor.d.ts.map +1 -1
  63. package/dist/core/executor.js +7 -3
  64. package/dist/core/executor.js.map +1 -1
  65. package/dist/core/flow.d.ts +19 -3
  66. package/dist/core/flow.d.ts.map +1 -1
  67. package/dist/core/flow.js +97 -58
  68. package/dist/core/flow.js.map +1 -1
  69. package/dist/core/session-mode.d.ts +1 -1
  70. package/dist/core/session-mode.d.ts.map +1 -1
  71. package/dist/core/session-mode.js +2 -1
  72. package/dist/core/session-mode.js.map +1 -1
  73. package/dist/core/{delegation.d.ts → transition.d.ts} +9 -9
  74. package/dist/core/transition.d.ts.map +1 -0
  75. package/dist/core/{delegation.js → transition.js} +17 -24
  76. package/dist/core/transition.js.map +1 -0
  77. package/dist/flow/auto-warp.d.ts +12 -0
  78. package/dist/flow/auto-warp.d.ts.map +1 -0
  79. package/dist/flow/auto-warp.js +29 -0
  80. package/dist/flow/auto-warp.js.map +1 -0
  81. package/dist/flow/command.d.ts.map +1 -1
  82. package/dist/flow/command.js +7 -2
  83. package/dist/flow/command.js.map +1 -1
  84. package/dist/flow/continuation.d.ts.map +1 -1
  85. package/dist/flow/continuation.js +52 -15
  86. package/dist/flow/continuation.js.map +1 -1
  87. package/dist/flow/index.d.ts +5 -2
  88. package/dist/flow/index.d.ts.map +1 -1
  89. package/dist/flow/index.js +6 -3
  90. package/dist/flow/index.js.map +1 -1
  91. package/dist/flow/loop-command.d.ts +8 -0
  92. package/dist/flow/loop-command.d.ts.map +1 -0
  93. package/dist/flow/loop-command.js +102 -0
  94. package/dist/flow/loop-command.js.map +1 -0
  95. package/dist/flow/loop-templates.d.ts +7 -0
  96. package/dist/flow/loop-templates.d.ts.map +1 -0
  97. package/dist/flow/loop-templates.js +38 -0
  98. package/dist/flow/loop-templates.js.map +1 -0
  99. package/dist/flow/loop.d.ts +19 -0
  100. package/dist/flow/loop.d.ts.map +1 -0
  101. package/dist/flow/loop.js +95 -0
  102. package/dist/flow/loop.js.map +1 -0
  103. package/dist/flow/settings-command.d.ts.map +1 -1
  104. package/dist/flow/settings-command.js +93 -4
  105. package/dist/flow/settings-command.js.map +1 -1
  106. package/dist/flow/store.d.ts +3 -3
  107. package/dist/flow/store.d.ts.map +1 -1
  108. package/dist/flow/store.js +24 -16
  109. package/dist/flow/store.js.map +1 -1
  110. package/dist/flow/template-shared.d.ts +9 -0
  111. package/dist/flow/template-shared.d.ts.map +1 -0
  112. package/dist/flow/template-shared.js +13 -0
  113. package/dist/flow/template-shared.js.map +1 -0
  114. package/dist/flow/template-strings.d.ts.map +1 -1
  115. package/dist/flow/template-strings.js +2 -5
  116. package/dist/flow/template-strings.js.map +1 -1
  117. package/dist/flow/types.d.ts +15 -9
  118. package/dist/flow/types.d.ts.map +1 -1
  119. package/dist/flow/warp.d.ts +15 -0
  120. package/dist/flow/warp.d.ts.map +1 -0
  121. package/dist/flow/warp.js +207 -0
  122. package/dist/flow/warp.js.map +1 -0
  123. package/dist/index.d.ts +3 -2
  124. package/dist/index.d.ts.map +1 -1
  125. package/dist/index.js +185 -28
  126. package/dist/index.js.map +1 -1
  127. package/dist/snapshot/cli-args.d.ts +1 -0
  128. package/dist/snapshot/cli-args.d.ts.map +1 -1
  129. package/dist/snapshot/cli-args.js +12 -0
  130. package/dist/snapshot/cli-args.js.map +1 -1
  131. package/dist/snapshot/runner-events.d.ts +5 -0
  132. package/dist/snapshot/runner-events.d.ts.map +1 -1
  133. package/dist/snapshot/runner-events.js +89 -15
  134. package/dist/snapshot/runner-events.js.map +1 -1
  135. package/dist/snapshot/snapshot.d.ts +22 -19
  136. package/dist/snapshot/snapshot.d.ts.map +1 -1
  137. package/dist/snapshot/snapshot.js +1063 -167
  138. package/dist/snapshot/snapshot.js.map +1 -1
  139. package/dist/steering/flow-prompt.d.ts +2 -2
  140. package/dist/steering/flow-prompt.d.ts.map +1 -1
  141. package/dist/steering/flow-prompt.js +4 -4
  142. package/dist/steering/flow-prompt.js.map +1 -1
  143. package/dist/steering/sliding-prompt.d.ts.map +1 -1
  144. package/dist/steering/sliding-prompt.js +9 -6
  145. package/dist/steering/sliding-prompt.js.map +1 -1
  146. package/dist/steering/tool-utils.d.ts +31 -8
  147. package/dist/steering/tool-utils.d.ts.map +1 -1
  148. package/dist/steering/tool-utils.js +63 -30
  149. package/dist/steering/tool-utils.js.map +1 -1
  150. package/dist/tools/ask-user.d.ts +0 -17
  151. package/dist/tools/ask-user.d.ts.map +1 -1
  152. package/dist/tools/ask-user.js +13 -37
  153. package/dist/tools/ask-user.js.map +1 -1
  154. package/dist/tools/timed-bash.d.ts +1 -1
  155. package/dist/tools/timed-bash.d.ts.map +1 -1
  156. package/dist/tools/timed-bash.js +19 -8
  157. package/dist/tools/timed-bash.js.map +1 -1
  158. package/dist/tools/web-tool.d.ts.map +1 -1
  159. package/dist/tools/web-tool.js +11 -13
  160. package/dist/tools/web-tool.js.map +1 -1
  161. package/dist/tui/render-utils.d.ts +5 -1
  162. package/dist/tui/render-utils.d.ts.map +1 -1
  163. package/dist/tui/render-utils.js +36 -10
  164. package/dist/tui/render-utils.js.map +1 -1
  165. package/dist/tui/render.d.ts +9 -0
  166. package/dist/tui/render.d.ts.map +1 -1
  167. package/dist/tui/render.js +112 -100
  168. package/dist/tui/render.js.map +1 -1
  169. package/dist/tui/scramble/constants.d.ts +8 -8
  170. package/dist/tui/scramble/constants.d.ts.map +1 -1
  171. package/dist/tui/scramble/constants.js +7 -7
  172. package/dist/tui/scramble/constants.js.map +1 -1
  173. package/dist/tui/scramble/index.d.ts +1 -1
  174. package/dist/tui/scramble/index.d.ts.map +1 -1
  175. package/dist/tui/scramble/index.js +1 -1
  176. package/dist/tui/scramble/index.js.map +1 -1
  177. package/dist/tui/scramble/manager.d.ts +1 -5
  178. package/dist/tui/scramble/manager.d.ts.map +1 -1
  179. package/dist/tui/scramble/manager.js +16 -64
  180. package/dist/tui/scramble/manager.js.map +1 -1
  181. package/dist/tui/scramble/utils.js +1 -1
  182. package/dist/tui/scramble/utils.js.map +1 -1
  183. package/dist/types/flow.d.ts +2 -0
  184. package/dist/types/flow.d.ts.map +1 -1
  185. package/dist/types/flow.js +11 -2
  186. package/dist/types/flow.js.map +1 -1
  187. package/dist/types/output.d.ts +6 -0
  188. package/dist/types/output.d.ts.map +1 -1
  189. package/package.json +2 -2
  190. package/dist/core/delegation.d.ts.map +0 -1
  191. package/dist/core/delegation.js.map +0 -1
  192. package/dist/flow/warp-command.d.ts +0 -9
  193. package/dist/flow/warp-command.d.ts.map +0 -1
  194. package/dist/flow/warp-command.js +0 -405
  195. package/dist/flow/warp-command.js.map +0 -1
@@ -2,8 +2,7 @@
2
2
  * Two JSONL protocols are used in this codebase:
3
3
  *
4
4
  * 1. Fork Snapshot Protocol (snapshot.ts):
5
- * Types: session, model_change, thinking_level_change, system, message,
6
- * compression-stats
5
+ * Types: session, model_change, thinking_level_change, message
7
6
  * Purpose: Serialized session state passed to child flows via --session.
8
7
  * Emitted by buildForkSessionSnapshotJsonl() and consumed by
9
8
  * sanitizeForkSnapshot() before forking.
@@ -18,7 +17,8 @@
18
17
  import { stripReasoningFromAssistantMessage } from "./reasoning-strip.js";
19
18
  import { stripSteeringHintFromContent, contentContainsSteeringHintTag, isJsonEqual, } from "../steering/sliding-prompt.js";
20
19
  import { stripStrategicHintsFromContent } from "../steering/tool-utils.js";
21
- import { logError } from "../config/log.js";
20
+ import { logWarn, logError } from "../config/log.js";
21
+ import * as path from "node:path";
22
22
  // ---------------------------------------------------------------------------
23
23
  // Session snapshot serialization
24
24
  // ---------------------------------------------------------------------------
@@ -26,6 +26,27 @@ export function buildForkSessionSnapshotJsonl(sessionManager) {
26
26
  const header = sessionManager.getHeader();
27
27
  if (!header || typeof header !== "object")
28
28
  return null;
29
+ // Compress cwd in session header: relative to repo root if under it,
30
+ // otherwise basename only. Saves ~50-100 bytes per snapshot.
31
+ const repoRoot = process.cwd();
32
+ let compressedHeader = header;
33
+ if (typeof compressedHeader.cwd === "string") {
34
+ const cwd = compressedHeader.cwd;
35
+ let compressedCwd;
36
+ if (cwd === repoRoot) {
37
+ compressedCwd = ".";
38
+ }
39
+ else if (cwd.startsWith(repoRoot + "/") || cwd.startsWith(repoRoot + "\\")) {
40
+ compressedCwd = cwd.slice(repoRoot.length + 1);
41
+ }
42
+ else {
43
+ const lastSep = Math.max(cwd.lastIndexOf("/"), cwd.lastIndexOf("\\"));
44
+ compressedCwd = lastSep >= 0 ? cwd.slice(lastSep + 1) : cwd;
45
+ }
46
+ if (compressedCwd !== cwd) {
47
+ compressedHeader = { ...compressedHeader, cwd: compressedCwd };
48
+ }
49
+ }
29
50
  const branchEntries = sessionManager.getBranch();
30
51
  const lines = [];
31
52
  // Emit session header once, unless getBranch() already includes it as the
@@ -38,13 +59,7 @@ export function buildForkSessionSnapshotJsonl(sessionManager) {
38
59
  typeof firstBranch !== "object" ||
39
60
  (firstType !== "session" && firstType !== "header") ||
40
61
  firstId !== headerId) {
41
- lines.push(JSON.stringify(header));
42
- }
43
- // Emit system event so the JSONL is self-contained — parsers can reconstruct
44
- // full context without needing the markdown section.
45
- const systemPrompt = header.systemPrompt;
46
- if (typeof systemPrompt === "string" && systemPrompt) {
47
- lines.push(JSON.stringify({ type: "system", content: systemPrompt }));
62
+ lines.push(JSON.stringify(compressedHeader));
48
63
  }
49
64
  for (const entry of branchEntries)
50
65
  lines.push(JSON.stringify(entry));
@@ -111,50 +126,18 @@ export function renderCompressedFlowResult(r) {
111
126
  }
112
127
  if (r.error)
113
128
  parts.push(`Error: ${r.error}`);
114
- const text = parts.join("\n");
115
- if (text.includes("undefined"))
129
+ // Safety net: reject malformed runtime data where required fields are missing.
130
+ // This is more precise than a substring search that would false-positive on
131
+ // legitimate content containing the word "undefined".
132
+ if (!r.type || !r.status)
116
133
  return undefined;
117
- return text;
118
- }
119
- // ---------------------------------------------------------------------------
120
- // batch_read result compression
121
- // ---------------------------------------------------------------------------
122
- /**
123
- * Extract file paths from a batch_read tool call's arguments.
124
- * Handles both { o: [...] } and bare array argument formats.
125
- */
126
- function extractBatchReadPaths(args) {
127
- if (!args || typeof args !== "object")
128
- return [];
129
- let ops;
130
- if (Array.isArray(args)) {
131
- ops = args;
132
- }
133
- else if (Array.isArray(args.o)) {
134
- ops = args.o;
135
- }
136
- else {
137
- return [];
138
- }
139
- const paths = [];
140
- for (const op of ops) {
141
- if (!op || typeof op !== "object")
142
- continue;
143
- const p = op.p;
144
- if (typeof p === "string" && p)
145
- paths.push(p);
146
- }
147
- return paths;
148
- }
149
- /**
150
- * Render a compressed batch_read result as compact metadata for child context.
151
- * Format: [batch_read] N ops → paths: file1.ts, file2.ts, …
152
- */
153
- function renderCompressedBatchReadResult(paths) {
154
- const MAX_PATHS_DISPLAY = 10;
155
- const display = paths.slice(0, MAX_PATHS_DISPLAY);
156
- const suffix = paths.length > MAX_PATHS_DISPLAY ? `, … +${paths.length - MAX_PATHS_DISPLAY} more` : "";
157
- return `[batch_read] ${paths.length} ops → paths: ${display.join(", ")}${suffix}`;
134
+ if (r.actions?.some((a) => !a.type || !a.description))
135
+ return undefined;
136
+ if (r.commands?.some((c) => !c.command))
137
+ return undefined;
138
+ if (r.notDone?.some((n) => !n.item))
139
+ return undefined;
140
+ return parts.join("\n");
158
141
  }
159
142
  // ---------------------------------------------------------------------------
160
143
  // Additional tool result compressors
@@ -170,29 +153,444 @@ const KNOWN_SECTION_HEADERS = [
170
153
  /^--- (.+) \((\d+) lines\) ---$/,
171
154
  /^--- (.+) (context map|file summary) ---$/,
172
155
  /^--- bash \[.+\] exit (\d+) ---$/,
156
+ /^--- bash \[.+\] pending ---$/,
157
+ /^--- bash \[.+\] error ---$/,
173
158
  /^--- edit: .+ ---$/,
174
159
  /^--- write: .+ ---$/,
175
160
  /^--- delete: .+ ---$/,
176
161
  /^--- read: .+ ---$/,
177
- /^--- (?!bash \[|edit:|write:|delete:|read:)(.+) ---$/,
162
+ /^--- rg: .+ ---$/,
163
+ /^--- (?!bash \[|edit:|write:|delete:|read:|rg:)(.+) ---$/,
178
164
  ];
179
165
  function isKnownSectionHeader(line) {
180
166
  return KNOWN_SECTION_HEADERS.some((re) => re.test(line));
181
167
  }
182
- /** Compress batch tool result: keep bash sections verbatim, truncate read content. */
183
- function compressBatchResult(text) {
168
+ /** Compress a single bash block into the X1 compact format. */
169
+ /** Convert legacy depth number to DepthPolicy. */
170
+ export function depthToPolicy(depth) {
171
+ const isDepth1 = depth < 2;
172
+ return { showPreviews: isDepth1, showBytes: isDepth1, showSupersededBreadcrumbs: isDepth1, showEditBlocks: isDepth1 };
173
+ }
174
+ function compressBashSection(bashId, status, exitCode, timingTier, stdoutLines, stderrLines, policy) {
175
+ const isDepth1 = policy.showPreviews;
176
+ const tier = timingTier ? ` · ${timingTier}` : "";
177
+ // Trim trailing empty lines inserted by multi-bash formatting
178
+ while (stdoutLines.length > 0 && stdoutLines[stdoutLines.length - 1] === "")
179
+ stdoutLines.pop();
180
+ while (stderrLines.length > 0 && stderrLines[stderrLines.length - 1] === "")
181
+ stderrLines.pop();
182
+ if (status === "ok") {
183
+ const lineCount = stdoutLines.length;
184
+ const linesLabel = lineCount === 1 ? "1 line" : `${lineCount} lines`;
185
+ if (isDepth1) {
186
+ if (lineCount === 0) {
187
+ return `[bash:ok] ${bashId} · exit ${exitCode}${tier} · 0 lines`;
188
+ }
189
+ const head = stdoutLines.slice(0, 3).join("\n");
190
+ return `[bash:ok] ${bashId} · exit ${exitCode}${tier} · ${linesLabel}\n> head:\n${head}`;
191
+ }
192
+ return `[bash:ok] ${bashId} · exit ${exitCode}`;
193
+ }
194
+ if (status === "pending") {
195
+ const lineCount = stdoutLines.length;
196
+ const linesLabel = lineCount === 1 ? "1 line partial" : `${lineCount} lines partial`;
197
+ if (isDepth1) {
198
+ if (lineCount === 0) {
199
+ return `[bash:pending] ${bashId} · still running · 0 lines partial`;
200
+ }
201
+ const head = stdoutLines.slice(0, 3).join("\n");
202
+ return `[bash:pending] ${bashId} · still running · ${linesLabel}\n> head:\n${head}`;
203
+ }
204
+ return `[bash:pending] ${bashId} · still running`;
205
+ }
206
+ if (status === "error") {
207
+ const lineCount = stderrLines.length;
208
+ const linesLabel = lineCount === 1 ? "1 line stderr" : `${lineCount} lines stderr`;
209
+ const exit = exitCode !== undefined ? ` · exit ${exitCode}` : "";
210
+ if (isDepth1) {
211
+ if (lineCount === 0) {
212
+ return `[bash:err] ${bashId}${exit}${tier} · 0 lines`;
213
+ }
214
+ const head = stderrLines.slice(0, 3).join("\n");
215
+ return `[bash:err] ${bashId}${exit}${tier} · ${linesLabel}\n> stderr:\n${head}`;
216
+ }
217
+ return `[bash:err] ${bashId}${exit}`;
218
+ }
219
+ return `[bash] ${bashId} · status unknown`;
220
+ }
221
+ /**
222
+ * Normalize a bash command string for use as a dedup key.
223
+ */
224
+ function normalizeBashCommand(cmd) {
225
+ return cmd.trim().replace(/\s+/g, " ");
226
+ }
227
+ /**
228
+ * Normalize a file path for use as a dedup key.
229
+ */
230
+ function normalizeDedupPath(rawPath, cwd) {
231
+ let p = rawPath.replace(/\\/g, "/");
232
+ if (p.startsWith("./")) {
233
+ p = p.slice(2);
234
+ }
235
+ p = path.resolve(cwd, p);
236
+ p = path.normalize(p);
237
+ return p;
238
+ }
239
+ /**
240
+ * Scan all batch tool results in the snapshot and build a DedupIndex.
241
+ * Only successful writes/edits/deletes are tracked; error operations are exempt.
242
+ */
243
+ function buildDedupIndex(lines, toolCallIdToName, toolCallIdToArgs, sessionCwd) {
244
+ const cwd = sessionCwd ?? process.cwd();
245
+ const latestWrite = new Map();
246
+ const latestEdit = new Map();
247
+ const latestDelete = new Map();
248
+ const latestWebSearch = new Map();
249
+ const latestWebFetch = new Map();
250
+ const latestAskUser = new Map();
251
+ const bashIdToCommand = new Map();
252
+ const latestBash = new Map();
253
+ for (const line of lines) {
254
+ let entry;
255
+ try {
256
+ entry = JSON.parse(line);
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ if (entry?.type !== "message" || (entry.message?.role !== "tool" && entry.message?.role !== "toolResult"))
262
+ continue;
263
+ const toolCallId = entry.message?.toolCallId;
264
+ if (typeof toolCallId !== "string")
265
+ continue;
266
+ const toolName = toolCallIdToName.get(toolCallId);
267
+ if (toolName === "batch") {
268
+ const text = extractToolResultText(entry) ?? "";
269
+ const textLines = text.replace(/\r\n/g, "\n").split("\n");
270
+ for (let i = 0; i < textLines.length; i++) {
271
+ const l = textLines[i];
272
+ // Successful write
273
+ const writeMatch = l.match(/^--- write: (.+) \((\d+) bytes\) ---$/);
274
+ if (writeMatch) {
275
+ const normPath = normalizeDedupPath(writeMatch[1].trim(), cwd);
276
+ latestWrite.set(normPath, toolCallId);
277
+ latestEdit.delete(normPath); // write supersedes earlier edits
278
+ continue;
279
+ }
280
+ // Error write — exempt from dedup
281
+ const errorWriteMatch = l.match(/^--- write: (.+) ---$/);
282
+ if (errorWriteMatch) {
283
+ continue;
284
+ }
285
+ // Successful edit
286
+ const editMatch = l.match(/^--- edit: (.+) \(([^)]*)\) ---$/);
287
+ if (editMatch) {
288
+ const normPath = normalizeDedupPath(editMatch[1].trim(), cwd);
289
+ latestEdit.set(normPath, toolCallId);
290
+ continue;
291
+ }
292
+ // Error edit — exempt from dedup
293
+ const errorEditMatch = l.match(/^--- edit: (.+) ---$/);
294
+ if (errorEditMatch) {
295
+ continue;
296
+ }
297
+ // Delete
298
+ const deleteMatch = l.match(/^--- delete: (.+) ---$/);
299
+ if (deleteMatch) {
300
+ const normPath = normalizeDedupPath(deleteMatch[1].trim(), cwd);
301
+ latestDelete.set(normPath, toolCallId);
302
+ latestWrite.delete(normPath); // delete supersedes earlier writes
303
+ latestEdit.delete(normPath); // delete supersedes earlier edits
304
+ }
305
+ }
306
+ // B1: Extract bash commands from batch args for cross-turn dedup
307
+ const args = toolCallIdToArgs.get(toolCallId);
308
+ if (args && typeof args === "object") {
309
+ const a = args;
310
+ const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
311
+ if (ops) {
312
+ for (const op of ops) {
313
+ if (op && typeof op === "object") {
314
+ const opObj = op;
315
+ if (opObj.o === "bash" || opObj.op === "bash") {
316
+ const cmd = typeof opObj.c === "string" ? opObj.c : "";
317
+ const id = typeof opObj.i === "string" ? opObj.i : "";
318
+ if (cmd && id) {
319
+ const normCmd = normalizeBashCommand(cmd);
320
+ bashIdToCommand.set(id, normCmd);
321
+ latestBash.set(normCmd, id);
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ // Ask_user tool results — build A1 dedup index
330
+ if (toolName === "ask_user") {
331
+ const args = toolCallIdToArgs.get(toolCallId);
332
+ if (args && typeof args === "object") {
333
+ const question = args.question;
334
+ if (typeof question === "string") {
335
+ const norm = question.trim().toLowerCase().slice(0, 120);
336
+ if (norm) {
337
+ latestAskUser.set(norm, toolCallId);
338
+ }
339
+ }
340
+ }
341
+ }
342
+ // Web tool results — build Q1 dedup index
343
+ if (toolName === "web") {
344
+ const args = toolCallIdToArgs.get(toolCallId);
345
+ if (args && typeof args === "object") {
346
+ const a = args;
347
+ const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
348
+ if (ops && ops.length > 0) {
349
+ const firstOp = ops[0];
350
+ if (firstOp && typeof firstOp === "object") {
351
+ const query = typeof firstOp.q === "string" ? firstOp.q.trim().toLowerCase() : undefined;
352
+ const url = typeof firstOp.u === "string" ? firstOp.u.trim().replace(/\/$/, "") : undefined;
353
+ if (query)
354
+ latestWebSearch.set(query, toolCallId);
355
+ if (url)
356
+ latestWebFetch.set(url, toolCallId);
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+ return { latestWrite, latestEdit, latestDelete, latestWebSearch, latestWebFetch, latestAskUser, bashIdToCommand, latestBash };
363
+ }
364
+ /** Check if a web tool result is superseded by a later result with the same query or URL. */
365
+ function checkWebDedup(args, toolCallId, dedupIndex) {
366
+ if (!args || typeof args !== "object")
367
+ return { isSuperseded: false };
368
+ const a = args;
369
+ const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
370
+ if (!ops || ops.length === 0)
371
+ return { isSuperseded: false };
372
+ const firstOp = ops[0];
373
+ if (!firstOp || typeof firstOp !== "object")
374
+ return { isSuperseded: false };
375
+ if (typeof firstOp.q === "string") {
376
+ const normQuery = firstOp.q.trim().toLowerCase();
377
+ if (normQuery) {
378
+ const latestTc = dedupIndex.latestWebSearch.get(normQuery);
379
+ if (latestTc !== toolCallId) {
380
+ return {
381
+ isSuperseded: true,
382
+ marker: `[web:search] "${firstOp.q}" (superseded by later search)`,
383
+ };
384
+ }
385
+ }
386
+ }
387
+ if (typeof firstOp.u === "string") {
388
+ const normUrl = firstOp.u.trim().replace(/\/$/, "");
389
+ if (normUrl) {
390
+ const latestTc = dedupIndex.latestWebFetch.get(normUrl);
391
+ if (latestTc !== toolCallId) {
392
+ return {
393
+ isSuperseded: true,
394
+ marker: `[web:fetch] ${firstOp.u} (superseded by later fetch)`,
395
+ };
396
+ }
397
+ }
398
+ }
399
+ return { isSuperseded: false };
400
+ }
401
+ /** Compress batch tool result: compress bash sections, truncate read content, dedup writes/edits/deletes (W1 + E1). */
402
+ function compressBatchResult(text, options = {}) {
403
+ const policy = options.depthPolicy ?? depthToPolicy(1);
404
+ const { toolCallId, latestWrite, latestEdit, latestDelete, bashIdToCommand, latestBash } = options;
405
+ const cwd = options.cwd ?? process.cwd();
184
406
  const lines = text.replace(/\r\n/g, "\n").split("\n");
407
+ // Pre-scan to find the last occurrence of each operation within this result.
408
+ // This handles the edge case where a single batch result contains multiple
409
+ // writes/edits/deletes to the same path.
410
+ const lastWriteIndex = new Map();
411
+ const lastEditIndex = new Map();
412
+ const lastDeleteIndex = new Map();
413
+ for (let j = 0; j < lines.length; j++) {
414
+ const w = lines[j].match(/^--- write: (.+) \((\d+) bytes\) ---$/);
415
+ if (w)
416
+ lastWriteIndex.set(normalizeDedupPath(w[1].trim(), cwd), j);
417
+ const e = lines[j].match(/^--- edit: (.+) \(([^)]*)\) ---$/);
418
+ if (e)
419
+ lastEditIndex.set(normalizeDedupPath(e[1].trim(), cwd), j);
420
+ const d = lines[j].match(/^--- delete: (.+) ---$/);
421
+ if (d)
422
+ lastDeleteIndex.set(normalizeDedupPath(d[1].trim(), cwd), j);
423
+ }
185
424
  const out = [];
186
425
  let i = 0;
426
+ const isSupersededWrite = (normPath, index) => {
427
+ if (!toolCallId)
428
+ return false;
429
+ const latestTc = latestWrite?.get(normPath);
430
+ if (latestTc !== toolCallId)
431
+ return true;
432
+ return lastWriteIndex.get(normPath) !== index;
433
+ };
434
+ const isSupersededEdit = (normPath, index) => {
435
+ if (!toolCallId)
436
+ return false;
437
+ const latestTc = latestEdit?.get(normPath);
438
+ if (latestTc !== toolCallId)
439
+ return true;
440
+ return lastEditIndex.get(normPath) !== index;
441
+ };
442
+ const isSupersededDelete = (normPath, index) => {
443
+ if (!toolCallId)
444
+ return false;
445
+ const latestTc = latestDelete?.get(normPath);
446
+ if (latestTc !== toolCallId)
447
+ return true;
448
+ return lastDeleteIndex.get(normPath) !== index;
449
+ };
187
450
  while (i < lines.length) {
188
451
  const line = lines[i];
189
- // File read section with content — truncate
190
- const readMatch = line.match(/^--- (.+) \((\d+) lines\) ---$/);
191
- if (readMatch) {
192
- out.push(`--- ${readMatch[1]} (${readMatch[2]} lines, content truncated) ---`);
452
+ // Bash section — compress with X1 protocol
453
+ const bashMatch = line.match(/^--- bash \[([^\]]+)\] (exit (\d+)|pending|error) ---$/);
454
+ if (bashMatch) {
455
+ const bashId = bashMatch[1];
456
+ const rawStatus = bashMatch[2];
457
+ const status = rawStatus.startsWith("exit") ? "ok" : rawStatus;
458
+ const exitCode = bashMatch[3] !== undefined ? Number(bashMatch[3]) : undefined;
459
+ // Stricter section-end check for bash content: don't treat generic
460
+ // `--- text ---` lines as section headers (they could be bash output).
461
+ const isBashSectionEnd = (l) => /^--- bash \[.+\]/.test(l) ||
462
+ /^--- (.+) \((\d+) lines\) ---$/.test(l) ||
463
+ /^--- (.+) (context map|file summary) ---$/.test(l) ||
464
+ /^--- edit: .+ ---$/.test(l) ||
465
+ /^--- write: .+ ---$/.test(l) ||
466
+ /^--- delete: .+ ---$/.test(l) ||
467
+ /^--- read: .+ ---$/.test(l) ||
468
+ /^--- rg: .+ ---$/.test(l);
469
+ // B1 cross-turn bash dedup
470
+ const normCmd = bashIdToCommand?.get(bashId);
471
+ const isSupersededBash = normCmd ? latestBash?.get(normCmd) !== bashId : false;
472
+ if (isSupersededBash) {
473
+ if (policy.showSupersededBreadcrumbs) {
474
+ const statusTag = status === "ok" ? "ok" : status === "pending" ? "pending" : "err";
475
+ out.push(`[bash:${statusTag}] ${bashId} (superseded)`);
476
+ }
477
+ i++;
478
+ while (i < lines.length && !isBashSectionEnd(lines[i])) {
479
+ i++;
480
+ }
481
+ continue;
482
+ }
193
483
  i++;
484
+ let timingTier;
485
+ const stdoutLines = [];
486
+ let stderrLines = [];
487
+ let inStderr = false;
488
+ while (i < lines.length && !isBashSectionEnd(lines[i])) {
489
+ const contentLine = lines[i];
490
+ const timingMatch = contentLine.match(/^\[Execution time: (.+)\]$/);
491
+ if (timingMatch) {
492
+ timingTier = timingMatch[1];
493
+ }
494
+ else if (contentLine === "[stderr]") {
495
+ inStderr = true;
496
+ }
497
+ else if (contentLine === "[partial output]") {
498
+ // pending partial output marker — stdout follows
499
+ inStderr = false;
500
+ }
501
+ else if (contentLine.startsWith("[Use batch_bash_poll")) {
502
+ // skip poll hint lines
503
+ }
504
+ else {
505
+ if (inStderr) {
506
+ stderrLines.push(contentLine);
507
+ }
508
+ else {
509
+ stdoutLines.push(contentLine);
510
+ }
511
+ }
512
+ i++;
513
+ }
514
+ // If error bash has no stderr but produced stdout, preserve the stdout
515
+ // as the error output so it isn't silently lost.
516
+ if (status === "error" && stderrLines.length === 0 && stdoutLines.length > 0) {
517
+ stderrLines = stdoutLines;
518
+ }
519
+ out.push(compressBashSection(bashId, status, exitCode, timingTier, stdoutLines, stderrLines, policy));
520
+ continue;
521
+ }
522
+ // R1: rg output compression
523
+ const rgMatch = line.match(/^--- rg: (.+) ---$/);
524
+ if (rgMatch) {
525
+ const rgPath = rgMatch[1].trim();
526
+ i++;
527
+ const rgLines = [];
194
528
  while (i < lines.length && !isKnownSectionHeader(lines[i])) {
529
+ rgLines.push(lines[i]);
530
+ i++;
531
+ }
532
+ // Trim trailing empty lines
533
+ while (rgLines.length > 0 && rgLines[rgLines.length - 1] === "")
534
+ rgLines.pop();
535
+ const matchCount = rgLines.length;
536
+ // Detect error: batch tool outputs "Error: <msg>" for failed rg ops
537
+ const firstNonEmpty = rgLines.find((l) => l.trim() !== "");
538
+ const isError = firstNonEmpty?.startsWith("Error:") ?? false;
539
+ if (isError) {
540
+ const lineCount = rgLines.filter((l) => l.trim() !== "").length;
541
+ const linesLabel = lineCount === 1 ? "1 line" : `${lineCount} lines`;
542
+ out.push(`[rg:err] ${rgPath} · ${linesLabel}`);
543
+ }
544
+ else if (matchCount === 0) {
545
+ out.push(`[rg:ok] ${rgPath} · 0 matches`);
546
+ }
547
+ else {
548
+ // Extract unique file paths from rg output (format: path:line:content)
549
+ const fileSet = new Set(rgLines
550
+ .map((l) => {
551
+ const colonIdx = l.indexOf(":");
552
+ return colonIdx > 0 ? l.slice(0, colonIdx) : "";
553
+ })
554
+ .filter(Boolean));
555
+ const fileCount = fileSet.size;
556
+ if (policy.showPreviews) {
557
+ const head = rgLines.slice(0, 3).join("\n");
558
+ out.push(`[rg:ok] ${rgPath} · ${matchCount} matches · ${fileCount} files\n> head:\n${head}`);
559
+ }
560
+ else {
561
+ out.push(`[rg:ok] ${rgPath} · ${matchCount} matches · ${fileCount} files`);
562
+ }
563
+ }
564
+ i--; // will be incremented by loop
565
+ continue;
566
+ }
567
+ // File read section with content — preview or truncate
568
+ const readMatch = line.match(/^--- (.+) \((\d+) lines\) ---$/);
569
+ if (readMatch) {
570
+ if (policy.showPreviews) {
571
+ i++;
572
+ const contentLines = [];
573
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
574
+ contentLines.push(lines[i]);
575
+ i++;
576
+ }
577
+ const head = contentLines.slice(0, 2).join("\n");
578
+ const tail = contentLines.slice(-2).join("\n");
579
+ let previewText;
580
+ if (contentLines.length > 4) {
581
+ previewText = `${head}\n[...${contentLines.length - 4} lines truncated...]\n${tail}`;
582
+ }
583
+ else {
584
+ previewText = contentLines.join("\n");
585
+ }
586
+ out.push(`--- ${readMatch[1]} (${readMatch[2]} lines, preview) ---\n${previewText}`);
587
+ }
588
+ else {
589
+ out.push(`--- ${readMatch[1]} (${readMatch[2]} lines, content truncated) ---`);
195
590
  i++;
591
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
592
+ i++;
593
+ }
196
594
  }
197
595
  continue;
198
596
  }
@@ -206,21 +604,160 @@ function compressBatchResult(text) {
206
604
  }
207
605
  continue;
208
606
  }
209
- // File read without line count — truncate
607
+ // File read without line count — preview or truncate
210
608
  // Negative lookahead excludes bash/edit/write/delete/read-error sections that should be kept verbatim
211
609
  const fallbackReadMatch = line.match(/^--- (?!bash \[|edit:|write:|delete:|read:)(.+) ---$/);
212
610
  if (fallbackReadMatch) {
213
- out.push(`--- ${fallbackReadMatch[1]} (content truncated) ---`);
611
+ if (policy.showPreviews) {
612
+ i++;
613
+ const contentLines = [];
614
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
615
+ contentLines.push(lines[i]);
616
+ i++;
617
+ }
618
+ const head = contentLines.slice(0, 2).join("\n");
619
+ const tail = contentLines.slice(-2).join("\n");
620
+ let previewText;
621
+ if (contentLines.length > 4) {
622
+ previewText = `${head}\n[...${contentLines.length - 4} lines truncated...]\n${tail}`;
623
+ }
624
+ else {
625
+ previewText = contentLines.join("\n");
626
+ }
627
+ out.push(`--- ${fallbackReadMatch[1]} (preview) ---\n${previewText}`);
628
+ }
629
+ else {
630
+ out.push(`--- ${fallbackReadMatch[1]} (content truncated) ---`);
631
+ i++;
632
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
633
+ i++;
634
+ }
635
+ }
636
+ continue;
637
+ }
638
+ // Write section — W1 dedup and compression
639
+ const writeMatch = line.match(/^--- write: (.+) \((\d+) bytes\) ---$/);
640
+ if (writeMatch) {
641
+ const rawPath = writeMatch[1].trim();
642
+ const normPath = normalizeDedupPath(rawPath, cwd);
643
+ const bytes = writeMatch[2];
644
+ if (isSupersededWrite(normPath, i)) {
645
+ if (policy.showSupersededBreadcrumbs) {
646
+ out.push(`[batch:write] ${rawPath} (superseded)`);
647
+ }
648
+ i++;
649
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
650
+ i++;
651
+ }
652
+ continue;
653
+ }
654
+ if (policy.showBytes) {
655
+ out.push(`[batch:write] ${rawPath} (${bytes} bytes)`);
656
+ }
657
+ else {
658
+ out.push(`[batch:write] ${rawPath}`);
659
+ }
660
+ i++;
661
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
662
+ i++;
663
+ }
664
+ continue;
665
+ }
666
+ // Error write — exempt from dedup, keep verbatim
667
+ const errorWriteMatch = line.match(/^--- write: (.+) ---$/);
668
+ if (errorWriteMatch) {
669
+ out.push(line);
670
+ i++;
671
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
672
+ out.push(lines[i]);
673
+ i++;
674
+ }
675
+ continue;
676
+ }
677
+ // Edit section — E1 dedup and compression
678
+ const editMatch = line.match(/^--- edit: (.+) \(([^)]*)\) ---$/);
679
+ if (editMatch) {
680
+ const rawPath = editMatch[1].trim();
681
+ const normPath = normalizeDedupPath(rawPath, cwd);
682
+ const blockInfo = editMatch[2];
683
+ if (isSupersededEdit(normPath, i)) {
684
+ if (policy.showSupersededBreadcrumbs) {
685
+ out.push(`[batch:edit] ${rawPath} (superseded)`);
686
+ }
687
+ i++;
688
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
689
+ i++;
690
+ }
691
+ continue;
692
+ }
693
+ if (policy.showEditBlocks) {
694
+ const blocksLabel = blockInfo ? ` (${blockInfo})` : "";
695
+ out.push(`[batch:edit] ${rawPath}${blocksLabel}`);
696
+ }
697
+ else {
698
+ out.push(`[batch:edit] ${rawPath}`);
699
+ }
700
+ i++;
701
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
702
+ i++;
703
+ }
704
+ continue;
705
+ }
706
+ // Error edit — exempt from dedup, keep verbatim
707
+ const errorEditMatch = line.match(/^--- edit: (.+) ---$/);
708
+ if (errorEditMatch) {
709
+ out.push(line);
710
+ i++;
711
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
712
+ out.push(lines[i]);
713
+ i++;
714
+ }
715
+ continue;
716
+ }
717
+ // Delete section — keep existing format for non-superseded, skip superseded
718
+ const deleteMatch = line.match(/^--- delete: (.+) ---$/);
719
+ if (deleteMatch) {
720
+ const rawPath = deleteMatch[1].trim();
721
+ const normPath = normalizeDedupPath(rawPath, cwd);
722
+ if (isSupersededDelete(normPath, i)) {
723
+ if (policy.showSupersededBreadcrumbs) {
724
+ out.push(`[batch:delete] ${rawPath} (superseded)`);
725
+ }
726
+ i++;
727
+ while (i < lines.length && !isKnownSectionHeader(lines[i])) {
728
+ i++;
729
+ }
730
+ continue;
731
+ }
732
+ out.push(line);
214
733
  i++;
215
734
  while (i < lines.length && !isKnownSectionHeader(lines[i])) {
735
+ out.push(lines[i]);
216
736
  i++;
217
737
  }
218
738
  continue;
219
739
  }
220
- // Everything else (bash, edit, write, delete, error, summary) — keep as-is
740
+ // Everything else (summary, error generic, etc.) — keep as-is
221
741
  out.push(line);
222
742
  i++;
223
743
  }
744
+ // B1: If every line in out is superseded or truncated noise, collapse to single summary
745
+ const meaningfulOut = out.filter((l) => l.trim() !== "");
746
+ // Single-pass rollup check: matches superseded breadcrumbs, truncated reads,
747
+ // and compact bash/rg lines. Equivalent to the previous multi-check logic.
748
+ const SUPERSEDED_OR_TRUNCATED_RE = /\(superseded\)|\(content truncated\)|\(context map, truncated\)|^\[bash:(ok|pending|err)\] |^\[bash:poll\] |^\[rg:(ok|err)\] /;
749
+ const isAllSupersededOrTruncated = meaningfulOut.length > 0 && meaningfulOut.every((l) => SUPERSEDED_OR_TRUNCATED_RE.test(l));
750
+ // At depth 2+, superseded writes/edits are dropped entirely (no breadcrumbs),
751
+ // leaving out empty. If the original text had only section headers and no
752
+ // summary or other kept content, rollup to a single line.
753
+ const meaningfulLines = lines.filter((l) => l.trim() !== "");
754
+ const allLinesWereSectionHeaders = meaningfulLines.length > 0 && meaningfulLines.every((l) => isKnownSectionHeader(l));
755
+ if (isAllSupersededOrTruncated || (allLinesWereSectionHeaders && meaningfulOut.length === 0)) {
756
+ const opCount = meaningfulLines.length > 0 ? String(meaningfulLines.length) : "0";
757
+ return policy.showSupersededBreadcrumbs
758
+ ? `[batch] ${opCount} ops (all superseded or truncated by later operations)`
759
+ : `[batch] ${opCount} ops (superseded)`;
760
+ }
224
761
  return out.join("\n");
225
762
  }
226
763
  /** Compress web tool result into compact metadata. */
@@ -233,8 +770,10 @@ function compressWebResult(text, args) {
233
770
  const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
234
771
  if (ops && ops.length > 0) {
235
772
  const firstOp = ops[0];
236
- query = typeof firstOp.q === "string" ? firstOp.q : undefined;
237
- url = typeof firstOp.u === "string" ? firstOp.u : undefined;
773
+ if (firstOp && typeof firstOp === "object") {
774
+ query = typeof firstOp.q === "string" ? firstOp.q : undefined;
775
+ url = typeof firstOp.u === "string" ? firstOp.u : undefined;
776
+ }
238
777
  }
239
778
  }
240
779
  // Search result format: numbered list
@@ -277,6 +816,113 @@ function compressAskUserResult(text, args) {
277
816
  }
278
817
  return `[ask_user] · ${text.length} chars`;
279
818
  }
819
+ /** Compress batch_bash_poll tool result into compact metadata (S4 + B1). */
820
+ function compressBatchBashPollResult(text, policy, options) {
821
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
822
+ const out = [];
823
+ let i = 0;
824
+ const isDepth1 = policy.showPreviews;
825
+ const isPollSectionEnd = (l) => /^--- \[.+\]/.test(l);
826
+ while (i < lines.length) {
827
+ const line = lines[i];
828
+ const completedMatch = line.match(/^--- \[([^\]]+)\] (exit (\d+)|interrupted) ---$/);
829
+ const pendingMatch = line.match(/^--- \[([^\]]+)\] still running ---$/);
830
+ if (completedMatch || pendingMatch) {
831
+ const id = (completedMatch ?? pendingMatch)[1];
832
+ const isCompleted = !!completedMatch;
833
+ const exitCode = completedMatch?.[3] !== undefined ? Number(completedMatch[3]) : undefined;
834
+ // B1 cross-turn bash dedup for poll results
835
+ const normCmd = options?.bashIdToCommand?.get(id);
836
+ const isSuperseded = normCmd ? options?.latestBash?.get(normCmd) !== id : false;
837
+ if (isSuperseded) {
838
+ if (policy.showSupersededBreadcrumbs) {
839
+ out.push(`[bash:poll] ${id} (superseded)`);
840
+ }
841
+ i++;
842
+ while (i < lines.length && !isPollSectionEnd(lines[i])) {
843
+ i++;
844
+ }
845
+ continue;
846
+ }
847
+ i++;
848
+ let timingTier;
849
+ const stdoutLines = [];
850
+ let stderrLines = [];
851
+ let inStderr = false;
852
+ while (i < lines.length && !isPollSectionEnd(lines[i])) {
853
+ const contentLine = lines[i];
854
+ const timingMatch = contentLine.match(/^\[Execution time: (.+)\]$/);
855
+ if (timingMatch) {
856
+ timingTier = timingMatch[1];
857
+ }
858
+ else if (contentLine === "[stderr]") {
859
+ inStderr = true;
860
+ }
861
+ else if (contentLine === "[output so far]") {
862
+ inStderr = false;
863
+ }
864
+ else if (contentLine.trim() === "") {
865
+ // skip empty lines between sections
866
+ }
867
+ else {
868
+ if (inStderr) {
869
+ stderrLines.push(contentLine);
870
+ }
871
+ else {
872
+ stdoutLines.push(contentLine);
873
+ }
874
+ }
875
+ i++;
876
+ }
877
+ const tier = timingTier ? ` · ${timingTier}` : "";
878
+ if (isCompleted) {
879
+ const statusTag = exitCode !== undefined ? `exit ${exitCode}` : "interrupted";
880
+ const statusLabel = exitCode === 0 ? "ok" : "error";
881
+ if (statusLabel === "error" && stderrLines.length === 0 && stdoutLines.length > 0) {
882
+ stderrLines = stdoutLines;
883
+ }
884
+ const targetLines = statusLabel === "error" ? stderrLines : stdoutLines;
885
+ const lineCount = targetLines.length;
886
+ if (isDepth1) {
887
+ if (lineCount === 0) {
888
+ out.push(`[bash:poll] ${id} · ${statusTag}${tier} · 0 lines`);
889
+ }
890
+ else {
891
+ const linesLabel = statusLabel === "error"
892
+ ? (lineCount === 1 ? "1 line stderr" : `${lineCount} lines stderr`)
893
+ : (lineCount === 1 ? "1 line" : `${lineCount} lines`);
894
+ const headPrefix = statusLabel === "error" ? "> stderr:" : "> head:";
895
+ const head = targetLines.slice(0, 3).join("\n");
896
+ out.push(`[bash:poll] ${id} · ${statusTag}${tier} · ${linesLabel}\n${headPrefix}\n${head}`);
897
+ }
898
+ }
899
+ else {
900
+ out.push(`[bash:poll] ${id} · ${statusTag}${tier}`);
901
+ }
902
+ }
903
+ else {
904
+ const lineCount = stdoutLines.length;
905
+ const linesLabel = lineCount === 1 ? "1 line partial" : `${lineCount} lines partial`;
906
+ if (isDepth1) {
907
+ if (lineCount === 0) {
908
+ out.push(`[bash:poll] ${id} · still running · 0 lines partial`);
909
+ }
910
+ else {
911
+ const head = stdoutLines.slice(0, 3).join("\n");
912
+ out.push(`[bash:poll] ${id} · still running · ${linesLabel}\n> head:\n${head}`);
913
+ }
914
+ }
915
+ else {
916
+ out.push(`[bash:poll] ${id} · still running`);
917
+ }
918
+ }
919
+ continue;
920
+ }
921
+ out.push(line);
922
+ i++;
923
+ }
924
+ return out.join("\n");
925
+ }
280
926
  // ---------------------------------------------------------------------------
281
927
  // Shared: toolCallId → toolName mapping
282
928
  // ---------------------------------------------------------------------------
@@ -320,7 +966,8 @@ function buildToolCallIdToNameMap(lines) {
320
966
  * - `batch_read` results: replaced with compact metadata (paths + op count)
321
967
  * since children have `batch` and can re-read files themselves.
322
968
  */
323
- export function compressToolResults(snapshot, cache) {
969
+ export function compressToolResults(snapshot, cache, depthPolicy) {
970
+ const policy = depthPolicy ?? depthToPolicy(1);
324
971
  const lines = snapshot.trimEnd().split("\n");
325
972
  // Quick check: if there are no flow cache entries and no compressible tool calls,
326
973
  // nothing to compress — return early.
@@ -331,7 +978,7 @@ export function compressToolResults(snapshot, cache) {
331
978
  return entry?.type === "message" && entry.message?.role === "assistant" &&
332
979
  Array.isArray(entry.message.content) &&
333
980
  entry.message.content.some((p) => p.type === "toolCall" &&
334
- ["batch_read", "batch", "web", "ask_user"].includes(p.name));
981
+ ["batch_read", "batch", "web", "ask_user", "batch_bash_poll"].includes(p.name));
335
982
  }
336
983
  catch {
337
984
  return false;
@@ -371,11 +1018,31 @@ export function compressToolResults(snapshot, cache) {
371
1018
  continue;
372
1019
  for (const part of content) {
373
1020
  if (part.type === "toolCall" && (part.id || part.toolCallId) && part.arguments) {
374
- toolCallIdToArgs.set(part.id ?? part.toolCallId, part.arguments);
1021
+ toolCallIdToArgs.set((part.id ?? part.toolCallId), part.arguments);
375
1022
  }
376
1023
  }
377
1024
  }
1025
+ // Extract session cwd from header for path normalization
1026
+ let sessionCwd = process.cwd();
1027
+ for (const line of lines) {
1028
+ let entry;
1029
+ try {
1030
+ entry = JSON.parse(line);
1031
+ }
1032
+ catch {
1033
+ continue;
1034
+ }
1035
+ if (entry?.type === "session" || entry?.type === "header") {
1036
+ if (typeof entry.cwd === "string") {
1037
+ sessionCwd = entry.cwd;
1038
+ break;
1039
+ }
1040
+ }
1041
+ }
1042
+ // === PASS 1 (pre-scan): Build DedupIndex for batch and web tool results (W1 + E1 + Q1) ===
1043
+ const dedupIndex = buildDedupIndex(lines, toolCallIdToName, toolCallIdToArgs, sessionCwd);
378
1044
  const result = [];
1045
+ let webSummaryEmitted = false;
379
1046
  // Second pass: compress matching tool results
380
1047
  for (const line of lines) {
381
1048
  let entry;
@@ -430,51 +1097,115 @@ export function compressToolResults(snapshot, cache) {
430
1097
  // Cache miss (never populated or evicted) — do NOT pass megabytes of raw
431
1098
  // flow output verbatim into child context. Render a minimal placeholder.
432
1099
  originalText = extractToolResultText(entry) ?? "";
433
- const rawContent = entry.message?.content;
434
- const contentSize = rawContent
435
- ? (typeof rawContent === "string" ? rawContent.length : JSON.stringify(rawContent).length)
436
- : 0;
437
- const size = originalText.length || contentSize || line.length;
438
- rendered = `[flow] prior result · ${size} chars — full context unavailable (result not cached at this depth)`;
1100
+ const flowArgs = toolCallIdToArgs.get(toolCallId);
1101
+ let flowTypeSuffix = '';
1102
+ if (flowArgs && typeof flowArgs === 'object') {
1103
+ const flowArr = Array.isArray(flowArgs.flow)
1104
+ ? flowArgs.flow
1105
+ : undefined;
1106
+ if (flowArr && flowArr.length > 0 && typeof flowArr[0].type === 'string') {
1107
+ flowTypeSuffix = `:${flowArr[0].type}`;
1108
+ }
1109
+ else if (typeof flowArgs.type === 'string') {
1110
+ flowTypeSuffix = `:${flowArgs.type}`;
1111
+ }
1112
+ }
1113
+ const statusLabel = entry.message.isError ? 'failed' : 'completed';
1114
+ rendered = `[flow${flowTypeSuffix}] ${statusLabel} · see prior session`;
439
1115
  }
440
1116
  else {
441
- const renderResults = compressed.map(renderCompressedFlowResult);
442
- const hasAnyUndefined = renderResults.some(r => r === undefined);
443
- if (hasAnyUndefined) {
444
- // Safety net: compression produced garbage, fall back to truncated raw.
445
- originalText = extractToolResultText(entry) ?? "";
446
- const size = originalText.length;
447
- rendered = size > 2000
448
- ? originalText.slice(0, 2000) + "\n[truncated]"
449
- : originalText;
450
- }
451
- else {
452
- rendered = renderResults.filter((r) => r !== undefined).join("\n\n");
1117
+ const renderedParts = [];
1118
+ for (const r of compressed) {
1119
+ const renderedResult = renderCompressedFlowResult(r);
1120
+ if (renderedResult === undefined) {
1121
+ // Granular fallback: only this element is malformed, don't waste
1122
+ // valid siblings by falling back the entire array to raw text.
1123
+ const flowType = r.type ?? "unknown";
1124
+ const status = r.status ?? "unknown";
1125
+ renderedParts.push(`[flow:${flowType}] ${status} (cache miss)`);
1126
+ }
1127
+ else {
1128
+ renderedParts.push(renderedResult);
1129
+ }
453
1130
  }
1131
+ rendered = renderedParts.join("\n\n");
454
1132
  }
455
1133
  }
456
- // Note: batch_read tool results are now compressed in stripBatchReadToolCalls
457
- // before compressToolResults runs, so this branch is no longer needed.
458
- // Kept as a no-op safety net for any edge cases.
459
- else if (toolName === "batch_read") {
460
- rendered = undefined; // handled upstream
461
- }
462
- // --- Compress batch tool results (selective: keep bash, truncate reads) ---
1134
+ // --- Compress batch tool results (selective: compress bash, truncate reads, dedup writes/edits/deletes) ---
463
1135
  else if (toolName === "batch") {
464
1136
  originalText = extractToolResultText(entry) ?? "";
465
- rendered = compressBatchResult(originalText);
1137
+ rendered = compressBatchResult(originalText, {
1138
+ depthPolicy: policy,
1139
+ toolCallId,
1140
+ latestWrite: dedupIndex.latestWrite,
1141
+ latestEdit: dedupIndex.latestEdit,
1142
+ latestDelete: dedupIndex.latestDelete,
1143
+ bashIdToCommand: dedupIndex.bashIdToCommand,
1144
+ latestBash: dedupIndex.latestBash,
1145
+ cwd: sessionCwd,
1146
+ });
1147
+ }
1148
+ // --- Compress batch_bash_poll tool results (S4 + B1) ---
1149
+ else if (toolName === "batch_bash_poll") {
1150
+ originalText = extractToolResultText(entry) ?? "";
1151
+ rendered = compressBatchBashPollResult(originalText, policy, {
1152
+ bashIdToCommand: dedupIndex.bashIdToCommand,
1153
+ latestBash: dedupIndex.latestBash,
1154
+ });
466
1155
  }
467
- // --- Compress web tool results ---
1156
+ // --- Compress web tool results (Q1 dedup) ---
468
1157
  else if (toolName === "web") {
469
1158
  originalText = extractToolResultText(entry) ?? "";
470
1159
  const args = toolCallIdToArgs.get(toolCallId);
471
- rendered = compressWebResult(originalText, args);
1160
+ const { isSuperseded, marker } = checkWebDedup(args, toolCallId, dedupIndex);
1161
+ if (isSuperseded) {
1162
+ if (policy.showSupersededBreadcrumbs) {
1163
+ rendered = marker;
1164
+ }
1165
+ else {
1166
+ // At depth 2+, drop superseded web results entirely
1167
+ continue;
1168
+ }
1169
+ }
1170
+ else {
1171
+ rendered = compressWebResult(originalText, args);
1172
+ if (!policy.showSupersededBreadcrumbs && !webSummaryEmitted) {
1173
+ const searchCount = dedupIndex.latestWebSearch.size;
1174
+ const fetchCount = dedupIndex.latestWebFetch.size;
1175
+ const total = searchCount + fetchCount;
1176
+ if (total > 0) {
1177
+ const fetchLabel = fetchCount === 1 ? "fetch" : "fetches";
1178
+ rendered = `[web] ${total} unique queries (${searchCount} searches, ${fetchCount} ${fetchLabel}) · latest per query below\n${rendered}`;
1179
+ webSummaryEmitted = true;
1180
+ }
1181
+ }
1182
+ }
472
1183
  }
473
- // --- Compress ask_user tool results ---
1184
+ // --- Compress ask_user tool results (A1 dedup) ---
474
1185
  else if (toolName === "ask_user") {
475
1186
  originalText = extractToolResultText(entry) ?? "";
476
1187
  const args = toolCallIdToArgs.get(toolCallId);
477
- rendered = compressAskUserResult(originalText, args);
1188
+ let question = "";
1189
+ if (args && typeof args === "object") {
1190
+ const q = args.question;
1191
+ if (typeof q === "string") {
1192
+ question = q;
1193
+ }
1194
+ }
1195
+ const normQuestion = question.trim().toLowerCase().slice(0, 120);
1196
+ const latestTc = normQuestion ? dedupIndex.latestAskUser.get(normQuestion) : undefined;
1197
+ if (latestTc && latestTc !== toolCallId) {
1198
+ if (policy.showSupersededBreadcrumbs) {
1199
+ rendered = `[ask_user] "${question}" (superseded by later ask_user)`;
1200
+ }
1201
+ else {
1202
+ // At depth 2+, drop superseded ask_user results entirely
1203
+ continue;
1204
+ }
1205
+ }
1206
+ else {
1207
+ rendered = compressAskUserResult(originalText, args);
1208
+ }
478
1209
  }
479
1210
  if (rendered !== undefined) {
480
1211
  logCompress(toolName ?? "unknown", originalText.length || line.length, rendered.length);
@@ -523,13 +1254,6 @@ function extractToolResultText(entry) {
523
1254
  }
524
1255
  return undefined;
525
1256
  }
526
- /**
527
- * Backward-compatible alias for compressToolResults.
528
- * @deprecated Use compressToolResults instead.
529
- */
530
- export function compressFlowToolResults(snapshot, cache) {
531
- return compressToolResults(snapshot, cache);
532
- }
533
1257
  // ---------------------------------------------------------------------------
534
1258
  // batch_read tool call stripping
535
1259
  // ---------------------------------------------------------------------------
@@ -543,6 +1267,44 @@ export function compressFlowToolResults(snapshot, cache) {
543
1267
  * results causes strict API providers (e.g. kimi-coding, DeepSeek) to reject
544
1268
  * the request with `tool_call_id is not found`.
545
1269
  */
1270
+ /**
1271
+ * Check if an assistant message is empty (continuation marker with no semantic value).
1272
+ * Empty means: no substantive text, no tool calls.
1273
+ */
1274
+ function isEmptyAssistantMessage(message) {
1275
+ if (message.role !== "assistant")
1276
+ return false;
1277
+ const content = message.content;
1278
+ // Null/undefined/empty string
1279
+ if (content === null || content === undefined || content === "")
1280
+ return true;
1281
+ // Whitespace-only string
1282
+ if (typeof content === "string" && content.trim() === "")
1283
+ return true;
1284
+ // Array content: check for no text parts or only whitespace text parts, and NO tool calls
1285
+ if (Array.isArray(content)) {
1286
+ const hasToolCall = content.some((p) => p.type === "toolCall");
1287
+ if (hasToolCall)
1288
+ return false;
1289
+ const textParts = content.filter((p) => p.type === "text" && typeof p.text === "string");
1290
+ if (textParts.length === 0)
1291
+ return true;
1292
+ const allWhitespace = textParts.every((p) => p.text.trim() === "");
1293
+ if (allWhitespace)
1294
+ return true;
1295
+ // Low-signal detection: short text with no actionable markers
1296
+ const fullText = textParts.map((p) => p.text).join("");
1297
+ if (fullText.length < 300) {
1298
+ const hasFilePath = /\w+\.\w+/.test(fullText);
1299
+ const hasToolReference = /\[[a-z_]+[^\]]*\]/.test(fullText);
1300
+ const hasCodeBlock = fullText.includes("```");
1301
+ const hasActionableMarkers = hasFilePath || hasToolReference || hasCodeBlock;
1302
+ if (!hasActionableMarkers)
1303
+ return true;
1304
+ }
1305
+ }
1306
+ return false;
1307
+ }
546
1308
  export function stripBatchReadToolCalls(snapshot) {
547
1309
  const lines = snapshot.trimEnd().split("\n");
548
1310
  // Pass 1: Collect all batch_read toolCallIds from assistant messages.
@@ -562,7 +1324,7 @@ export function stripBatchReadToolCalls(snapshot) {
562
1324
  continue;
563
1325
  for (const part of content) {
564
1326
  if (part.type === "toolCall" && part.name === "batch_read" && (part.id || part.toolCallId)) {
565
- batchReadToolCallIds.add(part.id ?? part.toolCallId);
1327
+ batchReadToolCallIds.add((part.id ?? part.toolCallId));
566
1328
  }
567
1329
  }
568
1330
  }
@@ -607,7 +1369,9 @@ export function stripBatchReadToolCalls(snapshot) {
607
1369
  }
608
1370
  const filteredContent = content.filter((part) => !(part.type === "toolCall" && part.name === "batch_read"));
609
1371
  if (filteredContent.length === 0) {
610
- filteredContent.push({ type: "text", text: "" });
1372
+ // Skip assistant messages that have no content after stripping batch_read
1373
+ // — an empty text placeholder wastes tokens and conveys nothing.
1374
+ continue;
611
1375
  }
612
1376
  result.push(JSON.stringify({
613
1377
  ...entry,
@@ -620,6 +1384,75 @@ export function stripBatchReadToolCalls(snapshot) {
620
1384
  return `${result.join("\n")}\n`;
621
1385
  }
622
1386
  // ---------------------------------------------------------------------------
1387
+ // Flow tool call argument compression
1388
+ // ---------------------------------------------------------------------------
1389
+ /**
1390
+ * Compress verbose `flow` tool call arguments in assistant messages.
1391
+ *
1392
+ * The child flow already receives its own `-p` activation prompt, so the full
1393
+ * mission text inside the JSONL assistant message is pure duplication.
1394
+ * Replaces the arguments with a compact summary `{type, aim, steps}`.
1395
+ */
1396
+ export function compressFlowToolCallArgs(snapshot) {
1397
+ const lines = snapshot.trimEnd().split("\n");
1398
+ const result = [];
1399
+ for (const line of lines) {
1400
+ let entry;
1401
+ try {
1402
+ entry = JSON.parse(line);
1403
+ }
1404
+ catch {
1405
+ result.push(line);
1406
+ continue;
1407
+ }
1408
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") {
1409
+ result.push(line);
1410
+ continue;
1411
+ }
1412
+ const content = entry.message.content;
1413
+ if (!Array.isArray(content)) {
1414
+ result.push(line);
1415
+ continue;
1416
+ }
1417
+ let modified = false;
1418
+ const newContent = content.map((part) => {
1419
+ if (part.type !== "toolCall" || part.name !== "flow")
1420
+ return part;
1421
+ const args = part.arguments;
1422
+ if (!args || typeof args !== "object")
1423
+ return part;
1424
+ const flowArr = Array.isArray(args.flow)
1425
+ ? args.flow
1426
+ : undefined;
1427
+ if (!flowArr || flowArr.length === 0)
1428
+ return part;
1429
+ const firstFlow = flowArr[0];
1430
+ const type = typeof firstFlow?.type === "string" ? firstFlow.type : undefined;
1431
+ const aim = typeof firstFlow?.aim === "string" ? firstFlow.aim : undefined;
1432
+ const steps = Array.isArray(firstFlow?.steps) ? firstFlow.steps.length : undefined;
1433
+ if (!type && !aim && steps === undefined)
1434
+ return part;
1435
+ modified = true;
1436
+ return {
1437
+ ...part,
1438
+ arguments: { type, aim, steps },
1439
+ };
1440
+ });
1441
+ if (!modified) {
1442
+ result.push(line);
1443
+ continue;
1444
+ }
1445
+ result.push(JSON.stringify({
1446
+ ...entry,
1447
+ message: {
1448
+ ...entry.message,
1449
+ content: newContent,
1450
+ },
1451
+ }));
1452
+ }
1453
+ return `${result.join("\n")}\n`;
1454
+ }
1455
+ // ---------------------------------------------------------------------------
623
1456
  // Reparent orphans
624
1457
  // ---------------------------------------------------------------------------
625
1458
  /**
@@ -635,42 +1468,58 @@ function reparentOrphans(snapshot) {
635
1468
  const id = entry?.message?.id ?? entry?.message?.messageId ?? entry?.id;
636
1469
  if (typeof id === "string" && id)
637
1470
  survivingIds.add(id);
638
- const parentId = entry?.parentId ?? entry?.parentMessageId ?? entry?.message?.parentId ?? entry?.message?.parentMessageId;
639
- if (typeof parentId === "string" && parentId && !(typeof id === "string" && id))
640
- survivingIds.add(parentId);
1471
+ // Only ids of actual entries should be in survivingIds; parentId refs
1472
+ // are checked in the second pass, not added here.
1473
+ }
1474
+ catch (err) {
1475
+ logWarn(`[pi-agent-flow] reparentOrphans id scan failed: ${err}`);
641
1476
  }
642
- catch { /* ignore */ }
643
1477
  }
644
1478
  for (let i = 0; i < lines.length; i++) {
645
1479
  try {
646
1480
  let entry = JSON.parse(lines[i]);
647
- const entryParentId = entry.parentId ?? entry.parentMessageId;
648
- const messageParentId = entry.message?.parentId ?? entry.message?.parentMessageId;
649
- const parentId = entryParentId ?? messageParentId;
650
- if (typeof parentId === "string" && parentId && !survivingIds.has(parentId)) {
651
- let modified = false;
652
- if (entry.parentId === parentId || entry.parentMessageId === parentId) {
653
- const { parentId: _pid, parentMessageId: _pmid, ...restEntry } = entry;
1481
+ let modified = false;
1482
+ const isMessageEntry = entry?.type === "message";
1483
+ // Fix top-level parentId only for message entries (not session headers).
1484
+ if (isMessageEntry) {
1485
+ if (typeof entry.parentId === "string" && entry.parentId && !survivingIds.has(entry.parentId)) {
1486
+ const { parentId: _pid, ...restEntry } = entry;
654
1487
  entry = restEntry;
655
1488
  modified = true;
656
1489
  }
657
- if (entry.message && (entry.message.parentId === parentId || entry.message.parentMessageId === parentId)) {
658
- const { parentId: _pid, parentMessageId: _pmid, ...restMessage } = entry.message;
1490
+ if (typeof entry.parentMessageId === "string" && entry.parentMessageId && !survivingIds.has(entry.parentMessageId)) {
1491
+ const { parentMessageId: _pmid, ...restEntry } = entry;
1492
+ entry = restEntry;
1493
+ modified = true;
1494
+ }
1495
+ }
1496
+ // Fix message-level parentId for all entries.
1497
+ const msg = entry.message;
1498
+ if (msg) {
1499
+ if (typeof msg.parentId === "string" && msg.parentId && !survivingIds.has(msg.parentId)) {
1500
+ const { parentId: _pid, ...restMessage } = msg;
659
1501
  entry = { ...entry, message: restMessage };
660
1502
  modified = true;
661
1503
  }
662
- if (modified) {
663
- lines[i] = JSON.stringify(entry);
1504
+ if (typeof msg.parentMessageId === "string" && msg.parentMessageId && !survivingIds.has(msg.parentMessageId)) {
1505
+ const { parentMessageId: _pmid, ...restMessage } = msg;
1506
+ entry = { ...entry, message: restMessage };
1507
+ modified = true;
664
1508
  }
665
1509
  }
1510
+ if (modified) {
1511
+ lines[i] = JSON.stringify(entry);
1512
+ }
1513
+ }
1514
+ catch (err) {
1515
+ logWarn(`[pi-agent-flow] reparentOrphans breadcrumb fix failed: ${err}`);
666
1516
  }
667
- catch { /* ignore */ }
668
1517
  }
669
1518
  return `${lines.join("\n")}\n`;
670
1519
  }
671
1520
  export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
672
1521
  if (!snapshot)
673
- return { result: snapshot, passesApplied: [] };
1522
+ return { result: snapshot, passesApplied: [], stats: null };
674
1523
  const preBytes = snapshot.length;
675
1524
  const lines = snapshot.trimEnd().split("\n");
676
1525
  const sanitizedLines = [];
@@ -681,29 +1530,26 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
681
1530
  try {
682
1531
  entry = JSON.parse(line);
683
1532
  }
684
- catch {
1533
+ catch (err) {
1534
+ logWarn(`[pi-agent-flow] sanitizeForkSnapshot parse failed: ${err}`);
685
1535
  sanitizedLines.push(line);
686
1536
  continue;
687
1537
  }
688
1538
  let changed = false;
689
- // Header (first line): merge fork metadata and replace parent system prompt.
1539
+ // Strip outer entry timestamp from all entries child replay doesn't need it
1540
+ // (JSONL line ordering is sufficient).
1541
+ if ("timestamp" in entry) {
1542
+ const { timestamp, ...restEntry } = entry;
1543
+ entry = restEntry;
1544
+ changed = true;
1545
+ subPasses.add("stripTimestamps");
1546
+ }
1547
+ // Header (first line): replace parent system prompt.
690
1548
  if (i === 0 && entry && typeof entry === "object") {
691
- // Inject fork metadata so children know their lineage.
692
- if (options && (options.forkedFrom || options.forkedAt || options.parentFlow || options.depth !== undefined)) {
693
- entry = {
694
- ...entry,
695
- ...(options.forkedFrom !== undefined ? { forkedFrom: options.forkedFrom } : {}),
696
- ...(options.forkedAt !== undefined ? { forkedAt: options.forkedAt } : {}),
697
- ...(options.parentFlow !== undefined ? { parentFlow: options.parentFlow } : {}),
698
- ...(options.depth !== undefined ? { depth: options.depth } : {}),
699
- };
700
- changed = true;
701
- subPasses.add("forkMetadataInjection");
702
- }
703
- // Replace the parent orchestrator system prompt with a brief note.
1549
+ // Replace the parent root state system prompt with a brief note.
704
1550
  // Children receive their own directive in the <activation> block.
705
1551
  if (entry.systemPrompt && typeof entry.systemPrompt === "string") {
706
- entry = { ...entry, systemPrompt: "[parent orchestrator system prompt stripped — child receives its own directive]" };
1552
+ entry = { ...entry, systemPrompt: "[parent root state system prompt stripped — child receives its own directive]" };
707
1553
  changed = true;
708
1554
  subPasses.add("stripSystemPrompt");
709
1555
  }
@@ -717,14 +1563,36 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
717
1563
  subPasses.add('stripSessionId');
718
1564
  }
719
1565
  }
720
- // Drop type: "system" entries the parent orchestrator system prompt was already
1566
+ // Whitelist session entry fields to prevent unknown metadata leaks.
1567
+ const isSessionHeader = i === 0 || entry?.type === "session";
1568
+ if (isSessionHeader && entry && typeof entry === "object") {
1569
+ const allowedHeaderKeys = new Set([
1570
+ "type", "systemPrompt", "version", "cwd",
1571
+ "forkedFrom", "forkedAt", "parentFlow", "depth", "parentId",
1572
+ "meta",
1573
+ ]);
1574
+ const entryKeys = Object.keys(entry);
1575
+ const hasUnknownHeaderField = entryKeys.some((k) => !allowedHeaderKeys.has(k));
1576
+ if (hasUnknownHeaderField) {
1577
+ const whitelisted = {};
1578
+ for (const key of entryKeys) {
1579
+ if (allowedHeaderKeys.has(key)) {
1580
+ whitelisted[key] = entry[key];
1581
+ }
1582
+ }
1583
+ entry = whitelisted;
1584
+ changed = true;
1585
+ subPasses.add("stripUnknownHeaderFields");
1586
+ }
1587
+ }
1588
+ // Drop type: "system" entries — the parent root state system prompt was already
721
1589
  // stripped from the header above. Standalone system events leak the full prompt.
722
1590
  // Children receive their own directive in the <activation> block.
723
1591
  if (entry?.type === "system") {
724
1592
  subPasses.add("dropSystemEvents");
725
1593
  continue;
726
1594
  }
727
- // Drop custom_message entries — hidden orchestrator instructions (e.g.
1595
+ // Drop custom_message entries — hidden root state instructions (e.g.
728
1596
  // flow continuation hook messages with display:false) that children
729
1597
  // should never see.
730
1598
  if (entry?.type === "custom_message") {
@@ -788,10 +1656,11 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
788
1656
  subPasses.add("stripTimestamps");
789
1657
  }
790
1658
  // Strip API metadata fields that children don't need (~5-7 KB per assistant message).
791
- // IMPORTANT: keep `usage` (including `totalTokens`). The child `pi` process replays
1659
+ // IMPORTANT: keep `usage.totalTokens` ONLY. The child `pi` process replays
792
1660
  // this JSONL and core/session code reads `message.usage.totalTokens`; stripping
793
1661
  // `usage` causes: Cannot read properties of undefined (reading 'totalTokens').
794
- // Strip `cost` from `usage` it's always zeros in forked context and children never need it.
1662
+ // Other fields (input, output, cacheRead, cacheWrite) are consumed only from
1663
+ // live child stdout events (runner-events.ts), never from fork snapshot replay.
795
1664
  if (message.role === "assistant") {
796
1665
  const { api, provider, model, stopReason, responseId, responseModel, usage, ...rest } = message;
797
1666
  let stripped = false;
@@ -799,12 +1668,16 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
799
1668
  stopReason !== undefined || responseId !== undefined || responseModel !== undefined) {
800
1669
  stripped = true;
801
1670
  }
802
- // Strip cost sub-object from usage while preserving totalTokens and other fields.
803
- let cleanedUsage = usage;
804
- if (usage && typeof usage === "object" && "cost" in usage) {
805
- const { cost, ...usageWithoutCost } = usage;
806
- cleanedUsage = usageWithoutCost;
807
- stripped = true;
1671
+ // Compress usage to totalTokens only child pi replay requires totalTokens.
1672
+ // Other fields (input, output, cacheRead, cacheWrite) are consumed only from
1673
+ // live child stdout events (runner-events.ts), never from fork snapshot replay.
1674
+ let cleanedUsage;
1675
+ if (usage && typeof usage === "object") {
1676
+ const ttl = usage.totalTokens;
1677
+ if (typeof ttl === "number") {
1678
+ cleanedUsage = { totalTokens: ttl };
1679
+ stripped = true;
1680
+ }
808
1681
  }
809
1682
  if (stripped) {
810
1683
  message = { ...rest, ...(cleanedUsage !== undefined ? { usage: cleanedUsage } : {}) };
@@ -812,6 +1685,20 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
812
1685
  subPasses.add("stripApiMetadata");
813
1686
  }
814
1687
  }
1688
+ // Collapse empty/low-signal assistant messages to a minimal continuation marker.
1689
+ if (message.role === "assistant" && isEmptyAssistantMessage(message)) {
1690
+ const totalTokens = message.usage?.totalTokens;
1691
+ const { usage: _usage, ...rest } = message;
1692
+ message = {
1693
+ ...rest,
1694
+ ...(totalTokens !== undefined ? { usage: { totalTokens } } : {}),
1695
+ content: totalTokens !== undefined
1696
+ ? `[assistant: ${totalTokens} tokens, no action]`
1697
+ : "[assistant:continuation]",
1698
+ };
1699
+ changed = true;
1700
+ subPasses.add("collapseEmptyAssistantMessages");
1701
+ }
815
1702
  // Strip `details` from tool/toolResult messages — carries FlowDetails UI metadata
816
1703
  // (mode, flowStyle, projectAgentsDir, results) that children never need.
817
1704
  if (message.role === "tool" || message.role === "toolResult") {
@@ -831,14 +1718,12 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
831
1718
  changed = true;
832
1719
  subPasses.add("stripSteeringHints");
833
1720
  }
834
- // Strip strategic hints from tool results
835
- if (message.role === "tool" || message.role === "toolResult") {
836
- const afterHints = stripStrategicHintsFromContent(modifiedContent);
837
- if (!isJsonEqual(afterHints, modifiedContent)) {
838
- modifiedContent = afterHints;
839
- changed = true;
840
- subPasses.add("stripStrategicHints");
841
- }
1721
+ // Strip strategic hints from all messages
1722
+ const afterHints = stripStrategicHintsFromContent(modifiedContent);
1723
+ if (!isJsonEqual(afterHints, modifiedContent)) {
1724
+ modifiedContent = afterHints;
1725
+ changed = true;
1726
+ subPasses.add("stripStrategicHints");
842
1727
  }
843
1728
  // Compress parent activation prompts in nested snapshot JSONL
844
1729
  // (detect user messages containing <context-seal> at depth >= 2).
@@ -892,37 +1777,48 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
892
1777
  sanitizedLines.push(outLine);
893
1778
  }
894
1779
  const passesApplied = [];
1780
+ const passDeltas = {};
1781
+ const measureBytes = (s) => new TextEncoder().encode(s).length;
895
1782
  let sanitized = `${sanitizedLines.join("\n")}\n`;
896
1783
  passesApplied.push(...subPasses);
1784
+ passDeltas["mainLoop"] = measureBytes(sanitized);
897
1785
  // Reparent orphaned parentIds after steering-hint messages were dropped.
898
1786
  sanitized = reparentOrphans(sanitized);
899
1787
  passesApplied.push("reparentOrphans");
1788
+ passDeltas["reparentOrphans1"] = measureBytes(sanitized);
900
1789
  // Strip batch_read tool calls from assistant messages.
901
1790
  // Children don't have batch_read in their active tools.
902
1791
  sanitized = stripBatchReadToolCalls(sanitized);
903
1792
  passesApplied.push("stripBatchRead");
1793
+ passDeltas["stripBatchRead"] = measureBytes(sanitized);
1794
+ // Compress verbose flow tool call arguments in assistant messages.
1795
+ sanitized = compressFlowToolCallArgs(sanitized);
1796
+ passesApplied.push("compressFlowToolCallArgs");
1797
+ passDeltas["compressFlowToolCallArgs"] = measureBytes(sanitized);
904
1798
  // Compress tool results (flow, batch, web, ask_user).
905
- sanitized = compressToolResults(sanitized, cache);
1799
+ sanitized = compressToolResults(sanitized, cache, depthToPolicy(options?.depth ?? 1));
906
1800
  passesApplied.push("compressToolResults");
1801
+ passDeltas["compressToolResults"] = measureBytes(sanitized);
907
1802
  // Reparent again after stripBatchRead and compressToolResults may have
908
1803
  // dropped additional messages, leaving new orphaned parentIds.
909
1804
  sanitized = reparentOrphans(sanitized);
910
1805
  passesApplied.push("reparentOrphans");
1806
+ passDeltas["reparentOrphans2"] = measureBytes(sanitized);
911
1807
  // Telemetry: measure total delta across sanitization, stripping, and compression.
912
1808
  const postBytes = sanitized.length;
913
- const reduction = preBytes > 0 ? ((1 - postBytes / preBytes) * 100).toFixed(0) : "0";
1809
+ const reduction = preBytes > 0 ? Math.round((1 - postBytes / preBytes) * 1000) / 10 : 0;
914
1810
  if (DEBUG_CONTEXT) {
915
1811
  logError(`[context-snapshot] pre: ${preBytes} → post: ${postBytes} bytes (${reduction}% reduction)`);
916
1812
  }
917
- // Always emit compression-stats as a trailing metadata entry so the dump contains
918
- // observability data regardless of DEBUG_CONTEXT setting.
919
- sanitized = sanitized.trimEnd() + "\n" + JSON.stringify({
920
- type: "compression-stats",
1813
+ // Stats are returned out-of-band for dump consumers only.
1814
+ // Do NOT append to child-visible JSONL — it's telemetry noise for the model.
1815
+ const stats = {
921
1816
  preBytes,
922
1817
  postBytes,
923
- reductionPercent: Number(reduction),
1818
+ reductionPercent: reduction,
924
1819
  passesApplied,
925
- }) + "\n";
926
- return { result: sanitized, passesApplied };
1820
+ passDeltas,
1821
+ };
1822
+ return { result: sanitized, passesApplied, stats };
927
1823
  }
928
1824
  //# sourceMappingURL=snapshot.js.map