@oh-my-pi/pi-coding-agent 13.14.2 → 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 (82) 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/export/html/template.css +43 -13
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.html +1 -0
  27. package/src/export/html/template.js +107 -0
  28. package/src/extensibility/extensions/types.ts +31 -8
  29. package/src/internal-urls/docs-index.generated.ts +1 -1
  30. package/src/lsp/index.ts +1 -1
  31. package/src/main.ts +44 -44
  32. package/src/mcp/oauth-discovery.ts +1 -1
  33. package/src/modes/acp/acp-agent.ts +957 -0
  34. package/src/modes/acp/acp-event-mapper.ts +531 -0
  35. package/src/modes/acp/acp-mode.ts +13 -0
  36. package/src/modes/acp/index.ts +2 -0
  37. package/src/modes/components/agent-dashboard.ts +5 -4
  38. package/src/modes/components/custom-editor.ts +47 -47
  39. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  40. package/src/modes/components/history-search.ts +2 -1
  41. package/src/modes/components/hook-editor.ts +2 -1
  42. package/src/modes/components/hook-input.ts +8 -7
  43. package/src/modes/components/hook-selector.ts +15 -10
  44. package/src/modes/components/keybinding-hints.ts +9 -9
  45. package/src/modes/components/login-dialog.ts +3 -3
  46. package/src/modes/components/mcp-add-wizard.ts +2 -1
  47. package/src/modes/components/model-selector.ts +14 -3
  48. package/src/modes/components/oauth-selector.ts +2 -1
  49. package/src/modes/components/session-selector.ts +2 -1
  50. package/src/modes/components/settings-selector.ts +2 -1
  51. package/src/modes/components/status-line-segment-editor.ts +2 -1
  52. package/src/modes/components/tree-selector.ts +3 -2
  53. package/src/modes/components/user-message-selector.ts +3 -8
  54. package/src/modes/components/user-message.ts +16 -0
  55. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  56. package/src/modes/controllers/input-controller.ts +29 -23
  57. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  58. package/src/modes/index.ts +1 -0
  59. package/src/modes/interactive-mode.ts +17 -5
  60. package/src/modes/print-mode.ts +1 -1
  61. package/src/modes/prompt-action-autocomplete.ts +7 -7
  62. package/src/modes/rpc/rpc-mode.ts +7 -2
  63. package/src/modes/rpc/rpc-types.ts +1 -0
  64. package/src/modes/theme/theme.ts +53 -44
  65. package/src/modes/types.ts +9 -2
  66. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  67. package/src/modes/utils/keybinding-matchers.ts +21 -0
  68. package/src/modes/utils/ui-helpers.ts +1 -1
  69. package/src/patch/hashline.ts +139 -127
  70. package/src/patch/index.ts +77 -59
  71. package/src/patch/shared.ts +19 -11
  72. package/src/prompts/tools/hashline.md +43 -116
  73. package/src/sdk.ts +34 -17
  74. package/src/session/agent-session.ts +123 -30
  75. package/src/session/session-manager.ts +32 -31
  76. package/src/tools/ask.ts +56 -30
  77. package/src/tools/bash-interceptor.ts +1 -39
  78. package/src/tools/bash-skill-urls.ts +1 -1
  79. package/src/tools/browser.ts +1 -1
  80. package/src/tools/gemini-image.ts +1 -1
  81. package/src/tools/resolve.ts +1 -1
  82. 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();
package/src/tools/ask.ts CHANGED
@@ -16,13 +16,12 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
- import type { Component } from "@oh-my-pi/pi-tui";
20
- import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
19
+ import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
21
20
  import { untilAborted } from "@oh-my-pi/pi-utils";
22
21
  import { type Static, Type } from "@sinclair/typebox";
23
22
  import { renderPromptTemplate } from "../config/prompt-templates";
24
23
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
25
- import { type Theme, theme } from "../modes/theme/theme";
24
+ import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
26
25
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
27
26
  import { renderStatusLine } from "../tui";
28
27
  import type { ToolSession } from ".";
@@ -574,10 +573,13 @@ interface AskRenderArgs {
574
573
  export const askToolRenderer = {
575
574
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
576
575
  const label = formatTitle("Ask", uiTheme);
576
+ const mdTheme = getMarkdownTheme();
577
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
577
578
 
578
579
  // Multi-part questions
579
580
  if (args.questions && args.questions.length > 0) {
580
- let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
581
+ const container = new Container();
582
+ container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
581
583
 
582
584
  for (let i = 0; i < args.questions.length; i++) {
583
585
  const q = args.questions[i];
@@ -585,25 +587,29 @@ export const askToolRenderer = {
585
587
  const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
586
588
  const continuation = isLastQ ? " " : uiTheme.tree.vertical;
587
589
 
588
- // Question line with metadata
589
590
  const meta: string[] = [];
590
591
  if (q.multi) meta.push("multi");
591
592
  if (q.options?.length) meta.push(`options:${q.options.length}`);
592
593
  const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
593
594
 
594
- text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
595
+ container.addChild(
596
+ new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
597
+ );
598
+ container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
595
599
 
596
- // Options under question
597
600
  if (q.options?.length) {
601
+ let optText = "";
598
602
  for (let j = 0; j < q.options.length; j++) {
599
603
  const opt = q.options[j];
600
604
  const isLastOpt = j === q.options.length - 1;
601
605
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
602
- text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
606
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
607
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
603
608
  }
609
+ container.addChild(new Text(optText, 0, 0));
604
610
  }
605
611
  }
606
- return new Text(text, 0, 0);
612
+ return container;
607
613
  }
608
614
 
609
615
  // Single question
@@ -611,22 +617,26 @@ export const askToolRenderer = {
611
617
  return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
612
618
  }
613
619
 
614
- let text = `${label} ${uiTheme.fg("accent", args.question)}`;
620
+ const container = new Container();
615
621
  const meta: string[] = [];
616
622
  if (args.multi) meta.push("multi");
617
623
  if (args.options?.length) meta.push(`options:${args.options.length}`);
618
- text += formatMeta(meta, uiTheme);
624
+ container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
625
+ container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
619
626
 
620
627
  if (args.options?.length) {
628
+ let optText = "";
621
629
  for (let i = 0; i < args.options.length; i++) {
622
630
  const opt = args.options[i];
623
631
  const isLast = i === args.options.length - 1;
624
632
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
625
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
633
+ const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
634
+ optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
626
635
  }
636
+ container.addChild(new Text(optText, 0, 0));
627
637
  }
628
638
 
629
- return new Text(text, 0, 0);
639
+ return container;
630
640
  },
631
641
 
632
642
  renderResult(
@@ -635,6 +645,9 @@ export const askToolRenderer = {
635
645
  uiTheme: Theme,
636
646
  ): Component {
637
647
  const { details } = result;
648
+ const mdTheme = getMarkdownTheme();
649
+ const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
650
+
638
651
  if (!details) {
639
652
  const txt = result.content[0];
640
653
  const fallback = txt?.type === "text" && txt.text ? txt.text : "";
@@ -655,7 +668,8 @@ export const askToolRenderer = {
655
668
  },
656
669
  uiTheme,
657
670
  );
658
- let text = header;
671
+ const container = new Container();
672
+ container.addChild(new Text(header, 0, 0));
659
673
 
660
674
  for (let i = 0; i < details.results.length; i++) {
661
675
  const r = details.results[i];
@@ -667,22 +681,31 @@ export const askToolRenderer = {
667
681
  ? uiTheme.styledSymbol("status.success", "success")
668
682
  : uiTheme.styledSymbol("status.warning", "warning");
669
683
 
670
- text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
684
+ container.addChild(
685
+ new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
686
+ );
687
+ container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
671
688
 
689
+ let answerText = "";
672
690
  if (r.customInput) {
673
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
691
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
674
692
  } else if (r.selectedOptions.length > 0) {
675
693
  for (let j = 0; j < r.selectedOptions.length; j++) {
676
694
  const isLast = j === r.selectedOptions.length - 1;
677
695
  const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
678
- text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
696
+ const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
697
+ uiTheme.fg("toolOutput", t),
698
+ );
699
+ answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
679
700
  }
680
701
  } else {
681
- text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
702
+ answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
703
+ }
704
+ if (answerText) {
705
+ container.addChild(new Text(answerText, 0, 0));
682
706
  }
683
707
  }
684
-
685
- return new Text(text, 0, 0);
708
+ return container;
686
709
  }
687
710
 
688
711
  // Single question result
@@ -693,25 +716,28 @@ export const askToolRenderer = {
693
716
  }
694
717
 
695
718
  const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
696
- const header = renderStatusLine(
697
- { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
698
- uiTheme,
699
- );
700
-
701
- let text = header;
719
+ const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
720
+ const container = new Container();
721
+ container.addChild(new Text(header, 0, 0));
722
+ container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
702
723
 
724
+ let answerText = "";
703
725
  if (details.customInput) {
704
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
726
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
705
727
  } else if (details.selectedOptions && details.selectedOptions.length > 0) {
706
728
  for (let i = 0; i < details.selectedOptions.length; i++) {
707
729
  const isLast = i === details.selectedOptions.length - 1;
708
730
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
709
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
731
+ const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
732
+ uiTheme.fg("toolOutput", t),
733
+ );
734
+ answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
710
735
  }
711
736
  } else {
712
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
737
+ answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
713
738
  }
739
+ container.addChild(new Text(answerText, 0, 0));
714
740
 
715
- return new Text(text, 0, 0);
741
+ return container;
716
742
  },
717
743
  };
@@ -5,45 +5,7 @@
5
5
  * this interceptor provides helpful error messages directing them to use
6
6
  * the specialized tools instead.
7
7
  */
8
- import type { BashInterceptorRule } from "../config/settings-schema";
9
-
10
- export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
11
- {
12
- pattern: "^\\s*(cat|head|tail|less|more)\\s+",
13
- tool: "read",
14
- message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
15
- },
16
- {
17
- pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
18
- tool: "grep",
19
- message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
20
- },
21
- {
22
- pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
23
- tool: "find",
24
- message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
25
- },
26
- {
27
- pattern: "^\\s*sed\\s+(-i|--in-place)",
28
- tool: "edit",
29
- message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
30
- },
31
- {
32
- pattern: "^\\s*perl\\s+.*-[pn]?i",
33
- tool: "edit",
34
- message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
35
- },
36
- {
37
- pattern: "^\\s*awk\\s+.*-i\\s+inplace",
38
- tool: "edit",
39
- message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
40
- },
41
- {
42
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
43
- tool: "write",
44
- message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
45
- },
46
- ];
8
+ import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
47
9
 
48
10
  export interface InterceptionResult {
49
11
  /** If true, the bash command should be blocked */
@@ -131,7 +131,7 @@ async function resolveInternalUrlToPath(
131
131
  return resolvedLocalPath;
132
132
  }
133
133
 
134
- if (!internalRouter || !internalRouter.canHandle(url)) {
134
+ if (!internalRouter?.canHandle(url)) {
135
135
  throw new ToolError(
136
136
  `Cannot resolve ${scheme}:// URL in bash command: ${url}\n` +
137
137
  "Internal URL router is unavailable for this protocol in the current session.",
@@ -564,7 +564,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
564
564
  if (this.#page && !this.#page.isClosed()) {
565
565
  return this.#page;
566
566
  }
567
- if (!this.#browser || !this.#browser.isConnected()) {
567
+ if (!this.#browser?.isConnected()) {
568
568
  return this.#resetBrowser(params);
569
569
  }
570
570
  this.#page = await this.#browser.newPage();