@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.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 (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
@@ -46,6 +46,7 @@ import {
46
46
  calculateRateLimitBackoffMs,
47
47
  getSupportedEfforts,
48
48
  isContextOverflow,
49
+ isUsageLimitError,
49
50
  modelsAreEqual,
50
51
  parseRateLimitReason,
51
52
  } from "@oh-my-pi/pi-ai";
@@ -224,6 +225,8 @@ export interface AgentSessionConfig {
224
225
  mcpDiscoveryEnabled?: boolean;
225
226
  /** MCP tool names to activate for the current session when discovery mode is enabled. */
226
227
  initialSelectedMCPToolNames?: string[];
228
+ /** Whether constructor-provided MCP defaults should be persisted immediately. */
229
+ persistInitialMCPToolSelection?: boolean;
227
230
  /** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
228
231
  defaultSelectedMCPServerNames?: string[];
229
232
  /** MCP tool names that should seed brand-new sessions created from this AgentSession. */
@@ -363,6 +366,7 @@ export class AgentSession {
363
366
  #followUpMessages: string[] = [];
364
367
  /** Messages queued to be included with the next user prompt as context ("asides"). */
365
368
  #pendingNextTurnMessages: CustomMessage[] = [];
369
+ #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
366
370
  #planModeState: PlanModeState | undefined;
367
371
  #planReferenceSent = false;
368
372
  #planReferencePath = "local://PLAN.md";
@@ -483,8 +487,11 @@ export class AgentSession {
483
487
  this.#pruneSelectedMCPToolNames();
484
488
  const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
485
489
  const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
490
+ const persistInitialMCPToolSelection =
491
+ config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
486
492
  if (
487
493
  this.#mcpDiscoveryEnabled &&
494
+ persistInitialMCPToolSelection &&
488
495
  !this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
489
496
  ) {
490
497
  this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
@@ -781,7 +788,6 @@ export class AgentSession {
781
788
  attempt: this.#retryAttempt,
782
789
  });
783
790
  this.#retryAttempt = 0;
784
- this.#resolveRetry();
785
791
  }
786
792
  }
787
793
 
@@ -857,6 +863,7 @@ export class AgentSession {
857
863
  const didRetry = await this.#handleRetryableError(msg);
858
864
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
859
865
  }
866
+ this.#resolveRetry();
860
867
 
861
868
  if (msg.stopReason === "aborted" && this.#checkpointState) {
862
869
  this.#checkpointState = undefined;
@@ -2566,6 +2573,74 @@ export class AgentSession {
2566
2573
  });
2567
2574
  }
2568
2575
 
2576
+ #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
2577
+ this.#pendingNextTurnMessages.push(message);
2578
+ if (!triggerTurn) return;
2579
+ const generation = this.#promptGeneration;
2580
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2581
+ return;
2582
+ }
2583
+ this.#scheduledHiddenNextTurnGeneration = generation;
2584
+ this.#schedulePostPromptTask(
2585
+ async () => {
2586
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2587
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2588
+ }
2589
+ if (this.#pendingNextTurnMessages.length === 0) {
2590
+ return;
2591
+ }
2592
+ try {
2593
+ await this.#promptQueuedHiddenNextTurnMessages();
2594
+ } catch {
2595
+ // Leave the hidden next-turn messages queued for the next explicit prompt.
2596
+ }
2597
+ },
2598
+ {
2599
+ generation,
2600
+ onSkip: () => {
2601
+ if (this.#scheduledHiddenNextTurnGeneration === generation) {
2602
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2603
+ }
2604
+ },
2605
+ },
2606
+ );
2607
+ }
2608
+
2609
+ async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
2610
+ if (this.#pendingNextTurnMessages.length === 0) {
2611
+ return;
2612
+ }
2613
+
2614
+ const queuedMessages = [...this.#pendingNextTurnMessages];
2615
+ this.#pendingNextTurnMessages = [];
2616
+ const message = queuedMessages[queuedMessages.length - 1];
2617
+ if (!message) {
2618
+ return;
2619
+ }
2620
+
2621
+ const prependMessages = queuedMessages.slice(0, -1);
2622
+ const textContent = this.#getCustomMessageTextContent(message);
2623
+ try {
2624
+ await this.#promptWithMessage(message, textContent, {
2625
+ prependMessages,
2626
+ skipPostPromptRecoveryWait: true,
2627
+ });
2628
+ } catch (error) {
2629
+ this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
2630
+ throw error;
2631
+ }
2632
+ }
2633
+
2634
+ #getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
2635
+ if (typeof message.content === "string") {
2636
+ return message.content;
2637
+ }
2638
+ return message.content
2639
+ .filter((content): content is TextContent => content.type === "text")
2640
+ .map(content => content.text)
2641
+ .join("");
2642
+ }
2643
+
2569
2644
  /**
2570
2645
  * Throw an error if the text is an extension command.
2571
2646
  */
@@ -2606,7 +2681,7 @@ export class AgentSession {
2606
2681
  };
2607
2682
  if (this.isStreaming) {
2608
2683
  if (options?.deliverAs === "nextTurn") {
2609
- this.#pendingNextTurnMessages.push(appMessage);
2684
+ this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
2610
2685
  return;
2611
2686
  }
2612
2687
 
@@ -2618,6 +2693,22 @@ export class AgentSession {
2618
2693
  return;
2619
2694
  }
2620
2695
 
2696
+ if (options?.deliverAs === "nextTurn") {
2697
+ if (options?.triggerTurn) {
2698
+ await this.agent.prompt(appMessage);
2699
+ return;
2700
+ }
2701
+ this.agent.appendMessage(appMessage);
2702
+ this.sessionManager.appendCustomMessageEntry(
2703
+ message.customType,
2704
+ message.content,
2705
+ message.display,
2706
+ message.details,
2707
+ message.attribution ?? "agent",
2708
+ );
2709
+ return;
2710
+ }
2711
+
2621
2712
  if (options?.triggerTurn) {
2622
2713
  await this.agent.prompt(appMessage);
2623
2714
  return;
@@ -2685,9 +2776,9 @@ export class AgentSession {
2685
2776
  return { steering, followUp };
2686
2777
  }
2687
2778
 
2688
- /** Number of pending messages (includes both steering and follow-up) */
2779
+ /** Number of pending messages (includes steering, follow-up, and next-turn messages) */
2689
2780
  get queuedMessageCount(): number {
2690
- return this.#steeringMessages.length + this.#followUpMessages.length;
2781
+ return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
2691
2782
  }
2692
2783
 
2693
2784
  /** Get pending messages (read-only) */
@@ -2829,6 +2920,7 @@ export class AgentSession {
2829
2920
  async abort(): Promise<void> {
2830
2921
  this.abortRetry();
2831
2922
  this.#promptGeneration++;
2923
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2832
2924
  this.#resolveTtsrResume();
2833
2925
  this.#cancelPostPromptTasks();
2834
2926
  this.agent.abort();
@@ -2878,6 +2970,7 @@ export class AgentSession {
2878
2970
  this.#steeringMessages = [];
2879
2971
  this.#followUpMessages = [];
2880
2972
  this.#pendingNextTurnMessages = [];
2973
+ this.#scheduledHiddenNextTurnGeneration = undefined;
2881
2974
 
2882
2975
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
2883
2976
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
@@ -3611,6 +3704,7 @@ export class AgentSession {
3611
3704
  this.#steeringMessages = [];
3612
3705
  this.#followUpMessages = [];
3613
3706
  this.#pendingNextTurnMessages = [];
3707
+ this.#scheduledHiddenNextTurnGeneration = undefined;
3614
3708
  this.#todoReminderCount = 0;
3615
3709
 
3616
3710
  // Inject the handoff document as a custom message
@@ -4278,7 +4372,9 @@ export class AgentSession {
4278
4372
  const shouldRetry =
4279
4373
  retrySettings.enabled &&
4280
4374
  attempt < retrySettings.maxRetries &&
4281
- (retryAfterMs !== undefined || this.#isRetryableErrorMessage(message));
4375
+ (retryAfterMs !== undefined ||
4376
+ this.#isTransientErrorMessage(message) ||
4377
+ isUsageLimitError(message));
4282
4378
  if (!shouldRetry) {
4283
4379
  lastError = error;
4284
4380
  break;
@@ -4476,8 +4572,9 @@ export class AgentSession {
4476
4572
  // =========================================================================
4477
4573
 
4478
4574
  /**
4479
- * Check if an error is retryable (overloaded, rate limit, server errors).
4575
+ * Check if an error is retryable (transient errors or usage limits).
4480
4576
  * Context overflow errors are NOT retryable (handled by compaction instead).
4577
+ * Usage-limit errors are retryable because the retry handler performs credential switching.
4481
4578
  */
4482
4579
  #isRetryableError(message: AssistantMessage): boolean {
4483
4580
  if (message.stopReason !== "error" || !message.errorMessage) return false;
@@ -4487,20 +4584,17 @@ export class AgentSession {
4487
4584
  if (isContextOverflow(message, contextWindow)) return false;
4488
4585
 
4489
4586
  const err = message.errorMessage;
4490
- return this.#isRetryableErrorMessage(err);
4587
+ return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
4491
4588
  }
4492
4589
 
4493
- #isRetryableErrorMessage(errorMessage: string): boolean {
4494
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded, stream stall
4495
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
4590
+ #isTransientErrorMessage(errorMessage: string): boolean {
4591
+ // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
4592
+ // service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
4593
+ return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
4496
4594
  errorMessage,
4497
4595
  );
4498
4596
  }
4499
4597
 
4500
- #isUsageLimitErrorMessage(errorMessage: string): boolean {
4501
- return /usage.?limit|usage_limit_reached|limit_reached|quota.?exceeded|resource.?exhausted/i.test(errorMessage);
4502
- }
4503
-
4504
4598
  #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
4505
4599
  const now = Date.now();
4506
4600
  const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
@@ -4582,7 +4676,7 @@ export class AgentSession {
4582
4676
  const errorMessage = message.errorMessage || "Unknown error";
4583
4677
  let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
4584
4678
 
4585
- if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
4679
+ if (this.model && isUsageLimitError(errorMessage)) {
4586
4680
  const retryAfterMs =
4587
4681
  this.#parseRetryAfterMsFromError(errorMessage) ??
4588
4682
  calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
@@ -4960,6 +5054,7 @@ export class AgentSession {
4960
5054
  this.#steeringMessages = [];
4961
5055
  this.#followUpMessages = [];
4962
5056
  this.#pendingNextTurnMessages = [];
5057
+ this.#scheduledHiddenNextTurnGeneration = undefined;
4963
5058
 
4964
5059
  // Flush pending writes before switching
4965
5060
  await this.sessionManager.flush();
@@ -5003,21 +5098,18 @@ export class AgentSession {
5003
5098
  const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
5004
5099
  const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
5005
5100
  const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
5006
-
5007
- if (hasThinkingEntry) {
5008
- this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
5009
- } else {
5010
- const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
5011
- this.#thinkingLevel = effectiveDefaultThinkingLevel;
5012
- this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
5013
- this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
5014
- }
5015
-
5016
- if (hasServiceTierEntry) {
5017
- this.agent.serviceTier = sessionContext.serviceTier;
5018
- } else {
5019
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
5020
- }
5101
+ const configuredServiceTier = this.settings.get("serviceTier");
5102
+ const nextThinkingLevel = resolveThinkingLevelForModel(
5103
+ this.model,
5104
+ hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
5105
+ );
5106
+ this.#thinkingLevel = nextThinkingLevel;
5107
+ this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
5108
+ this.agent.serviceTier = hasServiceTierEntry
5109
+ ? sessionContext.serviceTier
5110
+ : configuredServiceTier === "none"
5111
+ ? undefined
5112
+ : configuredServiceTier;
5021
5113
 
5022
5114
  this.#reconnectToAgent();
5023
5115
  return true;
@@ -5059,6 +5151,7 @@ export class AgentSession {
5059
5151
 
5060
5152
  // Clear pending messages (bound to old session state)
5061
5153
  this.#pendingNextTurnMessages = [];
5154
+ this.#scheduledHiddenNextTurnGeneration = undefined;
5062
5155
 
5063
5156
  // Flush pending writes before branching
5064
5157
  await this.sessionManager.flush();
@@ -1302,21 +1302,19 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
1302
1302
  }
1303
1303
  }
1304
1304
 
1305
- if (messageCount) {
1306
- const stats = storage.statSync(file);
1307
- sessions.push({
1308
- path: file,
1309
- id: header.id,
1310
- cwd: typeof header.cwd === "string" ? header.cwd : "",
1311
- title: header.title ?? shortSummary,
1312
- parentSessionPath: (header as SessionHeader).parentSession,
1313
- created: new Date(header.timestamp),
1314
- modified: stats.mtime,
1315
- messageCount,
1316
- firstMessage: firstMessage || "(no messages)",
1317
- allMessagesText: allMessages.join(" "),
1318
- });
1319
- }
1305
+ const stats = storage.statSync(file);
1306
+ sessions.push({
1307
+ path: file,
1308
+ id: header.id,
1309
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
1310
+ title: header.title ?? shortSummary,
1311
+ parentSessionPath: (header as SessionHeader).parentSession,
1312
+ created: new Date(header.timestamp),
1313
+ modified: stats.mtime,
1314
+ messageCount,
1315
+ firstMessage: firstMessage || "(no messages)",
1316
+ allMessagesText: allMessages.join(" "),
1317
+ });
1320
1318
  } catch {}
1321
1319
  }),
1322
1320
  );
@@ -1381,6 +1379,7 @@ export class SessionManager {
1381
1379
  #sessionName: string | undefined;
1382
1380
  #sessionFile: string | undefined;
1383
1381
  #flushed: boolean = false;
1382
+ #needsFullRewriteOnNextPersist: boolean = false;
1384
1383
  #fileEntries: FileEntry[] = [];
1385
1384
  #byId: Map<string, SessionEntry> = new Map();
1386
1385
  #labelsById: Map<string, string> = new Map();
@@ -1443,9 +1442,7 @@ export class SessionManager {
1443
1442
  this.#sessionId = header?.id ?? Snowflake.next();
1444
1443
  this.#sessionName = header?.title;
1445
1444
 
1446
- if (migrateToCurrentVersion(this.#fileEntries)) {
1447
- await this.#rewriteFile();
1448
- }
1445
+ this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1449
1446
 
1450
1447
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
1451
1448
 
@@ -1632,6 +1629,7 @@ export class SessionManager {
1632
1629
  this.#labelsById.clear();
1633
1630
  this.#leafId = null;
1634
1631
  this.#flushed = false;
1632
+ this.#needsFullRewriteOnNextPersist = false;
1635
1633
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
1636
1634
 
1637
1635
  if (this.persist) {
@@ -1774,6 +1772,7 @@ export class SessionManager {
1774
1772
  this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
1775
1773
  );
1776
1774
  await this.#writeEntriesAtomically(entries);
1775
+ this.#needsFullRewriteOnNextPersist = false;
1777
1776
  this.#flushed = true;
1778
1777
  });
1779
1778
  }
@@ -1782,6 +1781,16 @@ export class SessionManager {
1782
1781
  return this.persist;
1783
1782
  }
1784
1783
 
1784
+ /**
1785
+ * Force-persist all current entries to disk, even when no assistant message exists yet.
1786
+ * Used by ACP mode where session/new must create a discoverable session immediately.
1787
+ */
1788
+ async ensureOnDisk(): Promise<void> {
1789
+ if (!this.persist || !this.#sessionFile) return;
1790
+ if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
1791
+ await this.#rewriteFile();
1792
+ }
1793
+
1785
1794
  /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
1786
1795
  async flush(): Promise<void> {
1787
1796
  await this.#queuePersistTask(async () => {
@@ -1911,23 +1920,15 @@ export class SessionManager {
1911
1920
 
1912
1921
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
1913
1922
  if (!hasAssistant) {
1914
- // Mark as not flushed so when assistant arrives, all entries get written
1923
+ // Mark as not flushed so when assistant arrives, all entries get written.
1915
1924
  this.#flushed = false;
1916
1925
  return;
1917
1926
  }
1918
1927
 
1919
- if (!this.#flushed) {
1920
- this.#flushed = true;
1921
- void this.#queuePersistTask(async () => {
1922
- const writer = this.#ensurePersistWriter();
1923
- if (!writer) return;
1924
- const entries = await Promise.all(
1925
- this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)),
1926
- );
1927
- for (const persistedEntry of entries) {
1928
- await writer.write(persistedEntry);
1929
- }
1930
- });
1928
+ if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
1929
+ // Full flush: rewrite the entire file atomically to avoid
1930
+ // duplicating entries if the file already exists (e.g. from ensureOnDisk).
1931
+ void this.#rewriteFile();
1931
1932
  } else {
1932
1933
  void this.#queuePersistTask(async () => {
1933
1934
  const writer = this.#ensurePersistWriter();
@@ -32,6 +32,8 @@ export interface OutputSinkOptions {
32
32
  artifactId?: string;
33
33
  spillThreshold?: number;
34
34
  onChunk?: (chunk: string) => void;
35
+ /** Minimum ms between onChunk calls. 0 = every chunk (default). */
36
+ chunkThrottleMs?: number;
35
37
  }
36
38
 
37
39
  export interface TruncationResult {
@@ -521,6 +523,7 @@ export class OutputSink {
521
523
  #totalBytes = 0;
522
524
  #sawData = false;
523
525
  #truncated = false;
526
+ #lastChunkTime = 0;
524
527
 
525
528
  #file?: {
526
529
  path: string;
@@ -528,22 +531,46 @@ export class OutputSink {
528
531
  sink: Bun.FileSink;
529
532
  };
530
533
 
534
+ // Queue of chunks waiting for the file sink to be created.
535
+ #pendingFileWrites?: string[];
536
+ #fileReady = false;
537
+
531
538
  readonly #artifactPath?: string;
532
539
  readonly #artifactId?: string;
533
540
  readonly #spillThreshold: number;
534
541
  readonly #onChunk?: (chunk: string) => void;
542
+ readonly #chunkThrottleMs: number;
535
543
 
536
544
  constructor(options?: OutputSinkOptions) {
537
- const { artifactPath, artifactId, spillThreshold = DEFAULT_MAX_BYTES, onChunk } = options ?? {};
545
+ const {
546
+ artifactPath,
547
+ artifactId,
548
+ spillThreshold = DEFAULT_MAX_BYTES,
549
+ onChunk,
550
+ chunkThrottleMs = 0,
551
+ } = options ?? {};
538
552
  this.#artifactPath = artifactPath;
539
553
  this.#artifactId = artifactId;
540
554
  this.#spillThreshold = spillThreshold;
541
555
  this.#onChunk = onChunk;
556
+ this.#chunkThrottleMs = chunkThrottleMs;
542
557
  }
543
558
 
544
- async push(chunk: string): Promise<void> {
559
+ /**
560
+ * Push a chunk of output. The buffer management and onChunk callback run
561
+ * synchronously. File sink writes are deferred and serialized internally.
562
+ */
563
+ push(chunk: string): void {
545
564
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
546
- this.#onChunk?.(chunk);
565
+
566
+ // Throttled onChunk: only call the callback when enough time has passed.
567
+ if (this.#onChunk) {
568
+ const now = Date.now();
569
+ if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
570
+ this.#lastChunkTime = now;
571
+ this.#onChunk(chunk);
572
+ }
573
+ }
547
574
 
548
575
  const dataBytes = Buffer.byteLength(chunk, "utf-8");
549
576
  this.#totalBytes += dataBytes;
@@ -556,10 +583,9 @@ export class OutputSink {
556
583
  const threshold = this.#spillThreshold;
557
584
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
558
585
 
559
- // Write to file if already spilling or about to overflow
560
- if (this.#file != null || willOverflow) {
561
- const sink = await this.#ensureFileSink();
562
- await sink?.write(chunk);
586
+ // Write to artifact file if configured and past the threshold
587
+ if (this.#artifactPath && (this.#file != null || willOverflow)) {
588
+ this.#writeToFile(chunk);
563
589
  }
564
590
 
565
591
  if (!willOverflow) {
@@ -589,14 +615,64 @@ export class OutputSink {
589
615
  if (this.#file) this.#truncated = true;
590
616
  }
591
617
 
618
+ /**
619
+ * Write a chunk to the artifact file. Handles the async file sink creation
620
+ * by queuing writes until the sink is ready, then draining synchronously.
621
+ */
622
+ #writeToFile(chunk: string): void {
623
+ if (this.#fileReady && this.#file) {
624
+ // Fast path: file sink exists, write synchronously
625
+ this.#file.sink.write(chunk);
626
+ return;
627
+ }
628
+ // File sink not yet created — queue this chunk and kick off creation
629
+ if (!this.#pendingFileWrites) {
630
+ this.#pendingFileWrites = [chunk];
631
+ void this.#createFileSink();
632
+ } else {
633
+ this.#pendingFileWrites.push(chunk);
634
+ }
635
+ }
636
+
637
+ async #createFileSink(): Promise<void> {
638
+ if (!this.#artifactPath || this.#fileReady) return;
639
+ try {
640
+ const sink = Bun.file(this.#artifactPath).writer();
641
+ this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
642
+
643
+ // Flush existing buffer to file BEFORE it gets trimmed further.
644
+ if (this.#buffer.length > 0) {
645
+ sink.write(this.#buffer);
646
+ }
647
+
648
+ // Drain any chunks that arrived while the sink was being created
649
+ if (this.#pendingFileWrites) {
650
+ for (const pending of this.#pendingFileWrites) {
651
+ sink.write(pending);
652
+ }
653
+ this.#pendingFileWrites = undefined;
654
+ }
655
+
656
+ this.#fileReady = true;
657
+ } catch {
658
+ try {
659
+ await this.#file?.sink?.end();
660
+ } catch {
661
+ /* ignore */
662
+ }
663
+ this.#file = undefined;
664
+ this.#pendingFileWrites = undefined;
665
+ }
666
+ }
667
+
592
668
  createInput(): WritableStream<Uint8Array | string> {
593
669
  const dec = new TextDecoder("utf-8", { ignoreBOM: true });
594
- const finalize = async () => {
595
- await this.push(dec.decode());
670
+ const finalize = () => {
671
+ this.push(dec.decode());
596
672
  };
597
673
  return new WritableStream({
598
- write: async chunk => {
599
- await this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
674
+ write: chunk => {
675
+ this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
600
676
  },
601
677
  close: finalize,
602
678
  abort: finalize,
@@ -620,32 +696,6 @@ export class OutputSink {
620
696
  artifactId: this.#file?.artifactId,
621
697
  };
622
698
  }
623
-
624
- // -- private ---------------------------------------------------------------
625
-
626
- async #ensureFileSink(): Promise<Bun.FileSink | null> {
627
- if (!this.#artifactPath) return null;
628
- if (this.#file) return this.#file.sink;
629
-
630
- try {
631
- const sink = Bun.file(this.#artifactPath).writer();
632
- this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
633
-
634
- // Flush existing buffer to file BEFORE it gets trimmed further.
635
- if (this.#buffer.length > 0) {
636
- await sink.write(this.#buffer);
637
- }
638
- return sink;
639
- } catch {
640
- try {
641
- await this.#file?.sink?.end();
642
- } catch {
643
- /* ignore */
644
- }
645
- this.#file = undefined;
646
- return null;
647
- }
648
- }
649
699
  }
650
700
 
651
701
  // =============================================================================