@oh-my-pi/pi-coding-agent 15.0.2 → 15.1.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 (138) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/examples/custom-tools/README.md +11 -7
  3. package/examples/custom-tools/hello/index.ts +2 -2
  4. package/examples/extensions/README.md +19 -8
  5. package/examples/extensions/api-demo.ts +15 -19
  6. package/examples/extensions/hello.ts +5 -6
  7. package/examples/extensions/plan-mode.ts +1 -1
  8. package/examples/extensions/reload-runtime.ts +4 -3
  9. package/examples/extensions/with-deps/index.ts +4 -3
  10. package/examples/sdk/06-extensions.ts +4 -2
  11. package/package.json +7 -17
  12. package/src/autoresearch/tools/init-experiment.ts +38 -41
  13. package/src/autoresearch/tools/log-experiment.ts +32 -41
  14. package/src/autoresearch/tools/run-experiment.ts +3 -3
  15. package/src/autoresearch/tools/update-notes.ts +11 -11
  16. package/src/commit/agentic/tools/analyze-file.ts +4 -4
  17. package/src/commit/agentic/tools/git-file-diff.ts +4 -4
  18. package/src/commit/agentic/tools/git-hunk.ts +5 -5
  19. package/src/commit/agentic/tools/git-overview.ts +4 -4
  20. package/src/commit/agentic/tools/propose-changelog.ts +13 -13
  21. package/src/commit/agentic/tools/propose-commit.ts +6 -6
  22. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  23. package/src/commit/agentic/tools/schemas.ts +28 -28
  24. package/src/commit/agentic/tools/split-commit.ts +22 -21
  25. package/src/commit/analysis/summary.ts +4 -4
  26. package/src/commit/changelog/generate.ts +7 -11
  27. package/src/commit/shared-llm.ts +22 -34
  28. package/src/config/config-file.ts +35 -13
  29. package/src/config/model-registry.ts +9 -190
  30. package/src/config/models-config-schema.ts +166 -0
  31. package/src/config/settings-schema.ts +18 -0
  32. package/src/edit/index.ts +2 -2
  33. package/src/edit/modes/apply-patch.ts +7 -6
  34. package/src/edit/modes/patch.ts +18 -25
  35. package/src/edit/modes/replace.ts +18 -20
  36. package/src/eval/js/shared/rewrite-imports.ts +131 -10
  37. package/src/eval/py/executor.ts +233 -623
  38. package/src/eval/py/kernel.ts +27 -2
  39. package/src/exa/factory.ts +5 -4
  40. package/src/exa/mcp-client.ts +1 -1
  41. package/src/exa/researcher.ts +9 -20
  42. package/src/exa/search.ts +26 -52
  43. package/src/exa/types.ts +1 -1
  44. package/src/exa/websets.ts +54 -53
  45. package/src/exec/bash-executor.ts +2 -1
  46. package/src/extensibility/custom-commands/loader.ts +5 -3
  47. package/src/extensibility/custom-commands/types.ts +4 -2
  48. package/src/extensibility/custom-tools/loader.ts +5 -3
  49. package/src/extensibility/custom-tools/types.ts +7 -6
  50. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  51. package/src/extensibility/extensions/loader.ts +7 -3
  52. package/src/extensibility/extensions/types.ts +9 -5
  53. package/src/extensibility/extensions/wrapper.ts +1 -2
  54. package/src/extensibility/hooks/loader.ts +3 -1
  55. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  56. package/src/extensibility/hooks/types.ts +4 -2
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -0
  58. package/src/extensibility/shared-events.ts +1 -1
  59. package/src/extensibility/typebox.ts +391 -0
  60. package/src/goals/tools/goal-tool.ts +6 -12
  61. package/src/hashline/types.ts +4 -4
  62. package/src/hindsight/state.ts +2 -2
  63. package/src/index.ts +0 -2
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/types.ts +30 -38
  66. package/src/mcp/manager.ts +1 -1
  67. package/src/mcp/tool-bridge.ts +1 -1
  68. package/src/modes/components/session-observer-overlay.ts +12 -1
  69. package/src/modes/components/status-line/segments.ts +2 -1
  70. package/src/modes/controllers/command-controller.ts +27 -2
  71. package/src/modes/controllers/event-controller.ts +3 -4
  72. package/src/modes/interactive-mode.ts +1 -1
  73. package/src/modes/rpc/host-tools.ts +1 -1
  74. package/src/modes/rpc/rpc-client.ts +1 -1
  75. package/src/modes/rpc/rpc-types.ts +1 -1
  76. package/src/modes/theme/theme.ts +111 -117
  77. package/src/modes/types.ts +1 -1
  78. package/src/modes/utils/context-usage.ts +2 -2
  79. package/src/sdk.ts +31 -8
  80. package/src/session/agent-session.ts +74 -104
  81. package/src/session/messages.ts +16 -51
  82. package/src/session/session-manager.ts +22 -2
  83. package/src/session/streaming-output.ts +16 -6
  84. package/src/task/executor.ts +208 -86
  85. package/src/task/index.ts +15 -11
  86. package/src/task/render.ts +32 -5
  87. package/src/task/types.ts +54 -39
  88. package/src/tools/ask.ts +12 -12
  89. package/src/tools/ast-edit.ts +11 -15
  90. package/src/tools/ast-grep.ts +9 -10
  91. package/src/tools/bash.ts +9 -23
  92. package/src/tools/browser.ts +39 -53
  93. package/src/tools/calculator.ts +12 -11
  94. package/src/tools/checkpoint.ts +7 -7
  95. package/src/tools/debug.ts +40 -43
  96. package/src/tools/eval.ts +6 -8
  97. package/src/tools/find.ts +10 -13
  98. package/src/tools/gh.ts +71 -128
  99. package/src/tools/hindsight-recall.ts +4 -6
  100. package/src/tools/hindsight-reflect.ts +5 -5
  101. package/src/tools/hindsight-retain.ts +15 -17
  102. package/src/tools/image-gen.ts +31 -81
  103. package/src/tools/index.ts +4 -1
  104. package/src/tools/inspect-image.ts +8 -9
  105. package/src/tools/irc.ts +15 -27
  106. package/src/tools/job.ts +14 -21
  107. package/src/tools/read.ts +7 -8
  108. package/src/tools/recipe/index.ts +7 -9
  109. package/src/tools/render-mermaid.ts +12 -12
  110. package/src/tools/report-tool-issue.ts +4 -4
  111. package/src/tools/resolve.ts +11 -11
  112. package/src/tools/review.ts +14 -26
  113. package/src/tools/search-tool-bm25.ts +7 -9
  114. package/src/tools/search.ts +19 -22
  115. package/src/tools/ssh.ts +7 -7
  116. package/src/tools/todo-write.ts +26 -34
  117. package/src/tools/vim.ts +10 -26
  118. package/src/tools/write.ts +5 -5
  119. package/src/tools/yield.ts +100 -54
  120. package/src/web/search/index.ts +9 -24
  121. package/src/prompts/compaction/branch-summary-context.md +0 -5
  122. package/src/prompts/compaction/branch-summary-preamble.md +0 -2
  123. package/src/prompts/compaction/branch-summary.md +0 -30
  124. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  125. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  126. package/src/prompts/compaction/compaction-summary.md +0 -38
  127. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  128. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  129. package/src/prompts/system/auto-handoff-threshold-focus.md +0 -1
  130. package/src/prompts/system/file-operations.md +0 -10
  131. package/src/prompts/system/handoff-document.md +0 -49
  132. package/src/prompts/system/summarization-system.md +0 -3
  133. package/src/session/compaction/branch-summarization.ts +0 -324
  134. package/src/session/compaction/compaction.ts +0 -1420
  135. package/src/session/compaction/errors.ts +0 -31
  136. package/src/session/compaction/index.ts +0 -8
  137. package/src/session/compaction/pruning.ts +0 -91
  138. package/src/session/compaction/utils.ts +0 -184
@@ -26,6 +26,23 @@ import {
26
26
  type AgentTool,
27
27
  ThinkingLevel,
28
28
  } from "@oh-my-pi/pi-agent-core";
29
+ import {
30
+ AUTO_HANDOFF_THRESHOLD_FOCUS,
31
+ CompactionCancelledError,
32
+ type CompactionPreparation,
33
+ type CompactionResult,
34
+ calculateContextTokens,
35
+ calculatePromptTokens,
36
+ collectEntriesForBranchSummary,
37
+ compact,
38
+ estimateTokens,
39
+ generateBranchSummary,
40
+ generateHandoff,
41
+ prepareCompaction,
42
+ type SummaryOptions,
43
+ shouldCompact,
44
+ } from "@oh-my-pi/pi-agent-core/compaction";
45
+ import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
29
46
  import type {
30
47
  AssistantMessage,
31
48
  Context,
@@ -126,9 +143,7 @@ import { resolveMemoryBackend } from "../memory-backend";
126
143
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
127
144
  import type { PlanModeState } from "../plan-mode/state";
128
145
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
129
- import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
130
146
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
131
- import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" };
132
147
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
133
148
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
134
149
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -160,21 +175,6 @@ import { extractFileMentions, generateFileMentionMessages } from "../utils/file-
160
175
  import { buildNamedToolChoice } from "../utils/tool-choice";
161
176
  import type { AuthStorage } from "./auth-storage";
162
177
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
163
- import {
164
- CompactionCancelledError,
165
- type CompactionPreparation,
166
- type CompactionResult,
167
- calculateContextTokens,
168
- calculatePromptTokens,
169
- collectEntriesForBranchSummary,
170
- compact,
171
- estimateTokens,
172
- generateBranchSummary,
173
- prepareCompaction,
174
- type SummaryOptions,
175
- shouldCompact,
176
- } from "./compaction";
177
- import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
178
178
  import {
179
179
  type BashExecutionMessage,
180
180
  type CompactionSummaryMessage,
@@ -334,6 +334,17 @@ export interface PromptOptions {
334
334
  skipCompactionCheck?: boolean;
335
335
  }
336
336
 
337
+ /** Result from a handoff operation. */
338
+ export interface HandoffResult {
339
+ document: string;
340
+ savedPath?: string;
341
+ }
342
+
343
+ export interface SessionHandoffOptions {
344
+ autoTriggered?: boolean;
345
+ signal?: AbortSignal;
346
+ }
347
+
337
348
  /** Result from cycleModel() */
338
349
  export interface ModelCycleResult {
339
350
  model: Model;
@@ -369,17 +380,6 @@ export interface SessionStats {
369
380
  cost: number;
370
381
  }
371
382
 
372
- /** Result from handoff() */
373
- export interface HandoffResult {
374
- document: string;
375
- savedPath?: string;
376
- }
377
-
378
- interface HandoffOptions {
379
- autoTriggered?: boolean;
380
- signal?: AbortSignal;
381
- }
382
-
383
383
  /** Internal marker for hook messages queued through the agent loop */
384
384
  // ============================================================================
385
385
  // Constants
@@ -387,8 +387,6 @@ interface HandoffOptions {
387
387
 
388
388
  /** Standard thinking levels */
389
389
 
390
- const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt);
391
-
392
390
  type RetryFallbackChains = Record<string, string[]>;
393
391
 
394
392
  type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
@@ -512,6 +510,15 @@ const noOpUIContext: ExtensionUIContext = {
512
510
  setToolsExpanded: () => {},
513
511
  };
514
512
 
513
+ function createHandoffContext(document: string): string {
514
+ return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
515
+ }
516
+
517
+ function createHandoffFileName(date = new Date()): string {
518
+ const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
519
+ return `handoff-${fileTimestamp}.md`;
520
+ }
521
+
515
522
  // ============================================================================
516
523
  // ACP Permission Gate
517
524
  // ============================================================================
@@ -4999,6 +5006,7 @@ export class AgentSession {
4999
5006
  promptOverride: compactionPrep.hookPrompt,
5000
5007
  extraContext: compactionPrep.hookContext,
5001
5008
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5009
+ convertToLlm,
5002
5010
  },
5003
5011
  );
5004
5012
  summary = result.summary;
@@ -5135,16 +5143,13 @@ export class AgentSession {
5135
5143
  }
5136
5144
 
5137
5145
  /**
5138
- * Generate a handoff document by asking the agent, then start a new session with it.
5139
- *
5140
- * This prompts the current agent to write a comprehensive handoff document,
5141
- * waits for completion, then starts a fresh session with the handoff as context.
5146
+ * Generate a handoff document with a oneshot LLM call, then start a new session with it.
5142
5147
  *
5143
5148
  * @param customInstructions Optional focus for the handoff document
5144
5149
  * @param options Handoff execution options
5145
5150
  * @returns The handoff document text, or undefined if cancelled/failed
5146
5151
  */
5147
- async handoff(customInstructions?: string, options?: HandoffOptions): Promise<HandoffResult | undefined> {
5152
+ async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
5148
5153
  const entries = this.sessionManager.getBranch();
5149
5154
  const messageCount = entries.filter(e => e.type === "message").length;
5150
5155
 
@@ -5158,10 +5163,6 @@ export class AgentSession {
5158
5163
  const handoffAbortController = this.#handoffAbortController;
5159
5164
  const handoffSignal = handoffAbortController.signal;
5160
5165
  const sourceSignal = options?.signal;
5161
- const onHandoffAbort = () => {
5162
- this.agent.abort();
5163
- };
5164
- handoffSignal.addEventListener("abort", onHandoffAbort, { once: true });
5165
5166
  const onSourceAbort = () => {
5166
5167
  if (!handoffSignal.aborted) {
5167
5168
  handoffAbortController.abort();
@@ -5174,71 +5175,36 @@ export class AgentSession {
5174
5175
  }
5175
5176
  }
5176
5177
 
5177
- // Build the handoff prompt
5178
- const handoffPrompt = prompt.render(handoffDocumentPrompt, {
5179
- additionalFocus: customInstructions,
5180
- });
5181
-
5182
- // Create a promise that resolves when the agent completes
5183
- let handoffText: string | undefined;
5184
- const { promise: completionPromise, resolve: resolveCompletion } = Promise.withResolvers<void>();
5185
- let handoffCancelled = false;
5186
- let unsubscribe: (() => void) | undefined;
5187
- const onCompletionAbort = () => {
5188
- unsubscribe?.();
5189
- handoffCancelled = true;
5190
- resolveCompletion();
5191
- };
5192
- if (handoffSignal.aborted) {
5193
- onCompletionAbort();
5194
- } else {
5195
- handoffSignal.addEventListener("abort", onCompletionAbort, { once: true });
5196
- }
5197
- unsubscribe = this.subscribe(event => {
5198
- if (event.type === "agent_end") {
5199
- unsubscribe?.();
5200
- handoffSignal.removeEventListener("abort", onCompletionAbort);
5201
- // Extract text from the last assistant message
5202
- const messages = this.agent.state.messages;
5203
- for (let i = messages.length - 1; i >= 0; i--) {
5204
- const msg = messages[i];
5205
- if (msg.role === "assistant") {
5206
- const content = (msg as AssistantMessage).content;
5207
- const textParts = content
5208
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
5209
- .map(c => c.text);
5210
- if (textParts.length > 0) {
5211
- handoffText = textParts.join("\n");
5212
- break;
5213
- }
5214
- }
5215
- }
5216
- resolveCompletion();
5217
- }
5218
- });
5219
-
5220
5178
  try {
5221
- // Send the prompt and wait for completion
5222
5179
  if (handoffSignal.aborted) {
5223
5180
  throw new Error("Handoff cancelled");
5224
5181
  }
5225
- this.#beginInFlight();
5226
- try {
5227
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
5228
- await this.#promptAgentWithIdleRetry([
5229
- {
5230
- role: "developer",
5231
- content: [{ type: "text", text: handoffPrompt }],
5232
- attribution: "agent",
5233
- timestamp: Date.now(),
5234
- },
5235
- ]);
5236
- } finally {
5237
- this.#endInFlight();
5182
+
5183
+ const model = this.model;
5184
+ if (!model) {
5185
+ throw new Error("No model selected for handoff");
5238
5186
  }
5239
- await completionPromise;
5187
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
5188
+ if (!apiKey) {
5189
+ throw new Error(`No API key for ${model.provider}`);
5190
+ }
5191
+
5192
+ const handoffText = await generateHandoff(
5193
+ this.agent.state.messages,
5194
+ model,
5195
+ apiKey,
5196
+ {
5197
+ systemPrompt: this.#baseSystemPrompt,
5198
+ tools: this.agent.state.tools,
5199
+ customInstructions,
5200
+ convertToLlm,
5201
+ initiatorOverride: "agent",
5202
+ metadata: this.agent.metadataForProvider(model.provider),
5203
+ },
5204
+ handoffSignal,
5205
+ );
5240
5206
 
5241
- if (handoffCancelled || handoffSignal.aborted) {
5207
+ if (handoffSignal.aborted) {
5242
5208
  throw new Error("Handoff cancelled");
5243
5209
  }
5244
5210
  if (!handoffText) {
@@ -5261,15 +5227,14 @@ export class AgentSession {
5261
5227
  this.#todoReminderCount = 0;
5262
5228
 
5263
5229
  // Inject the handoff document as a custom message
5264
- const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
5230
+ const handoffContent = createHandoffContext(handoffText);
5265
5231
  this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
5266
5232
  await this.sessionManager.ensureOnDisk();
5267
5233
  let savedPath: string | undefined;
5268
5234
  if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
5269
5235
  const artifactsDir = this.sessionManager.getArtifactsDir();
5270
5236
  if (artifactsDir) {
5271
- const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
5272
- const handoffFilePath = path.join(artifactsDir, `handoff-${fileTimestamp}.md`);
5237
+ const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
5273
5238
  try {
5274
5239
  await Bun.write(handoffFilePath, `${handoffText}\n`);
5275
5240
  savedPath = handoffFilePath;
@@ -5290,10 +5255,12 @@ export class AgentSession {
5290
5255
  this.#syncTodoPhasesFromBranch();
5291
5256
 
5292
5257
  return { document: handoffText, savedPath };
5258
+ } catch (error) {
5259
+ if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
5260
+ throw new Error("Handoff cancelled");
5261
+ }
5262
+ throw error;
5293
5263
  } finally {
5294
- unsubscribe?.();
5295
- handoffSignal.removeEventListener("abort", onCompletionAbort);
5296
- handoffSignal.removeEventListener("abort", onHandoffAbort);
5297
5264
  sourceSignal?.removeEventListener("abort", onSourceAbort);
5298
5265
  this.#handoffAbortController = undefined;
5299
5266
  }
@@ -5954,6 +5921,7 @@ export class AgentSession {
5954
5921
  return await compact(preparation, candidate, apiKey, customInstructions, signal, {
5955
5922
  ...options,
5956
5923
  metadata: this.agent.metadataForProvider(candidate.provider),
5924
+ convertToLlm,
5957
5925
  });
5958
5926
  } catch (error) {
5959
5927
  if (!this.#isCompactionAuthFailure(error)) {
@@ -6206,6 +6174,7 @@ export class AgentSession {
6206
6174
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
6207
6175
  metadata: this.agent.metadataForProvider(candidate.provider),
6208
6176
  initiatorOverride: "agent",
6177
+ convertToLlm,
6209
6178
  });
6210
6179
  break;
6211
6180
  } catch (error) {
@@ -7809,6 +7778,7 @@ export class AgentSession {
7809
7778
  customInstructions: options.customInstructions,
7810
7779
  reserveTokens: branchSummarySettings.reserveTokens,
7811
7780
  metadata: this.agent.metadataForProvider(model.provider),
7781
+ convertToLlm,
7812
7782
  });
7813
7783
  this.#branchSummaryAbortController = undefined;
7814
7784
  if (result.aborted) {
@@ -5,24 +5,31 @@
5
5
  * and provides a transformer to convert them to LLM-compatible messages.
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
+ import {
9
+ type BranchSummaryMessage,
10
+ type CompactionSummaryMessage,
11
+ renderBranchSummaryContext,
12
+ renderCompactionSummaryContext,
13
+ } from "@oh-my-pi/pi-agent-core/compaction/messages";
8
14
  import type {
9
15
  AssistantMessage,
10
16
  ImageContent,
11
17
  Message,
12
18
  MessageAttribution,
13
- ProviderPayload,
14
19
  TextContent,
15
20
  ToolResultMessage,
16
21
  } from "@oh-my-pi/pi-ai";
17
- import { prompt } from "@oh-my-pi/pi-utils";
18
- import branchSummaryContextPrompt from "../prompts/compaction/branch-summary-context.md" with { type: "text" };
19
- import compactionSummaryContextPrompt from "../prompts/compaction/compaction-summary-context.md" with { type: "text" };
22
+
23
+ export {
24
+ type BranchSummaryMessage,
25
+ type CompactionSummaryMessage,
26
+ createBranchSummaryMessage,
27
+ createCompactionSummaryMessage,
28
+ } from "@oh-my-pi/pi-agent-core/compaction/messages";
29
+
20
30
  import type { OutputMeta } from "../tools/output-meta";
21
31
  import { formatOutputNotice } from "../tools/output-meta";
22
32
 
23
- const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt;
24
- const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt;
25
-
26
33
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
27
34
 
28
35
  export interface SkillPromptDetails {
@@ -168,22 +175,6 @@ export interface HookMessage<T = unknown> {
168
175
  timestamp: number;
169
176
  }
170
177
 
171
- export interface BranchSummaryMessage {
172
- role: "branchSummary";
173
- summary: string;
174
- fromId: string;
175
- timestamp: number;
176
- }
177
-
178
- export interface CompactionSummaryMessage {
179
- role: "compactionSummary";
180
- summary: string;
181
- shortSummary?: string;
182
- tokensBefore: number;
183
- providerPayload?: ProviderPayload;
184
- timestamp: number;
185
- }
186
-
187
178
  /**
188
179
  * Message type for auto-read file mentions via @filepath syntax.
189
180
  */
@@ -254,32 +245,6 @@ export function pythonExecutionToText(msg: PythonExecutionMessage): string {
254
245
  return text;
255
246
  }
256
247
 
257
- export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
258
- return {
259
- role: "branchSummary",
260
- summary,
261
- fromId,
262
- timestamp: new Date(timestamp).getTime(),
263
- };
264
- }
265
-
266
- export function createCompactionSummaryMessage(
267
- summary: string,
268
- tokensBefore: number,
269
- timestamp: string,
270
- shortSummary?: string,
271
- providerPayload?: ProviderPayload,
272
- ): CompactionSummaryMessage {
273
- return {
274
- role: "compactionSummary",
275
- summary,
276
- shortSummary,
277
- tokensBefore,
278
- providerPayload,
279
- timestamp: new Date(timestamp).getTime(),
280
- };
281
- }
282
-
283
248
  export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: AssistantMessage): AssistantMessage {
284
249
  if (message.providerPayload?.type !== "openaiResponsesHistory") {
285
250
  return message;
@@ -376,7 +341,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
376
341
  content: [
377
342
  {
378
343
  type: "text" as const,
379
- text: prompt.render(BRANCH_SUMMARY_TEMPLATE, { summary: m.summary }),
344
+ text: renderBranchSummaryContext(m.summary),
380
345
  },
381
346
  ],
382
347
  attribution: "agent",
@@ -388,7 +353,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
388
353
  content: [
389
354
  {
390
355
  type: "text" as const,
391
- text: prompt.render(COMPACTION_SUMMARY_TEMPLATE, { summary: m.summary }),
356
+ text: renderCompactionSummaryContext(m.summary),
392
357
  },
393
358
  ],
394
359
  attribution: "agent",
@@ -1351,6 +1351,11 @@ class NdjsonFileWriter {
1351
1351
  getError(): Error | undefined {
1352
1352
  return this.#error;
1353
1353
  }
1354
+
1355
+ /** True while the writer accepts new writes (not closing or closed). */
1356
+ isOpen(): boolean {
1357
+ return !this.#closed && !this.#closing;
1358
+ }
1354
1359
  }
1355
1360
 
1356
1361
  /** Get recent sessions for display in welcome screen */
@@ -2106,7 +2111,15 @@ export class SessionManager {
2106
2111
  #ensurePersistWriter(): NdjsonFileWriter | undefined {
2107
2112
  if (!this.persist || !this.#sessionFile) return undefined;
2108
2113
  if (this.#persistError) throw this.#persistError;
2109
- if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) return this.#persistWriter;
2114
+ if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
2115
+ if (this.#persistWriter.isOpen()) return this.#persistWriter;
2116
+ // Cached writer for the current file is mid-close (queued
2117
+ // `#closePersistWriterInternal` has flipped `#closing` but not yet
2118
+ // cleared `#persistWriter`). Returning it would make `writeSync`
2119
+ // throw "Writer closed". Defer to the caller — `_persist` routes
2120
+ // the entry through the async rewrite path so it still lands on disk.
2121
+ return undefined;
2122
+ }
2110
2123
  // Note: caller must await _closePersistWriter() before calling this if switching files
2111
2124
  this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
2112
2125
  onError: err => {
@@ -2457,7 +2470,14 @@ export class SessionManager {
2457
2470
  // line referencing them is written.
2458
2471
  try {
2459
2472
  const writer = this.#ensurePersistWriter();
2460
- if (!writer) return;
2473
+ if (!writer) {
2474
+ // `#ensurePersistWriter` returns undefined here only when the cached
2475
+ // writer is mid-close (the `!persist`/`!sessionFile` cases are
2476
+ // rejected above). Route through `#rewriteFile` so the entry — which
2477
+ // is already in `#fileEntries` — persists once the close drains.
2478
+ this.#rewriteFile().catch(() => {});
2479
+ return;
2480
+ }
2461
2481
  const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
2462
2482
  writer.writeSync(persistedEntry);
2463
2483
  } catch (err) {
@@ -644,6 +644,7 @@ export class OutputSink {
644
644
  #head = "";
645
645
  #headBytes = 0;
646
646
  #headLines = 0; // newline count inside #head
647
+ #headRetentionDisabled = false;
647
648
  #totalLines = 0; // newline count
648
649
  #totalBytes = 0;
649
650
  #sawData = false;
@@ -740,7 +741,7 @@ export class OutputSink {
740
741
  // exhausted, then forward any leftover to the tail buffer.
741
742
  let tailChunk = capped;
742
743
  let tailBytes = cappedBytes;
743
- if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) {
744
+ if (this.#headLimit > 0 && !this.#headRetentionDisabled && this.#headBytes < this.#headLimit) {
744
745
  const room = this.#headLimit - this.#headBytes;
745
746
  if (cappedBytes <= room) {
746
747
  this.#head += capped;
@@ -919,12 +920,16 @@ export class OutputSink {
919
920
  }
920
921
 
921
922
  /**
922
- * Replace the in-memory buffer with the given text while preserving the
923
- * streaming counters (totalLines/totalBytes reflect the raw chunks that
924
- * already reached the sink). Used when an upstream minimizer rewrites the
925
- * captured output after the raw bytes have already been streamed.
923
+ * Replace the in-memory buffer with the given text. Used when an upstream
924
+ * minimizer rewrites the captured output after the raw bytes have already
925
+ * been streamed.
926
926
  *
927
- * Clears any retained head window the minimized text is authoritative.
927
+ * After this call the buffer is authoritative: streaming counters realign
928
+ * to the replacement, the retained head window is cleared, and head
929
+ * retention is disabled so subsequent `push()` calls append directly to the
930
+ * tail buffer instead of repopulating the (now meaningless) head window
931
+ * — which would otherwise reorder content and trip the middle-elision
932
+ * branch in `dump()` against stale totals.
928
933
  */
929
934
  replace(text: string): void {
930
935
  this.#buffer = text;
@@ -932,6 +937,11 @@ export class OutputSink {
932
937
  this.#head = "";
933
938
  this.#headBytes = 0;
934
939
  this.#headLines = 0;
940
+ this.#headRetentionDisabled = true;
941
+ this.#totalBytes = this.#bufferBytes;
942
+ this.#totalLines = countNewlines(text);
943
+ this.#sawData = text.length > 0;
944
+ this.#truncated = false;
935
945
  this.#currentLineBytes = 0;
936
946
  this.#columnEllipsisAdded = false;
937
947
  this.#columnDroppedBytes = 0;