@oh-my-pi/pi-coding-agent 16.1.22 → 16.1.23

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 (45) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/cli.js +3441 -3300
  3. package/dist/types/cli/gc-cli.d.ts +58 -0
  4. package/dist/types/commands/gc.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +54 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  8. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  9. package/dist/types/modes/theme/theme.d.ts +1 -0
  10. package/dist/types/session/session-listing.d.ts +10 -1
  11. package/dist/types/system-prompt.d.ts +2 -0
  12. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  13. package/package.json +12 -12
  14. package/scripts/bench-guard.ts +1 -1
  15. package/src/cli/gc-cli.ts +939 -0
  16. package/src/cli-commands.ts +1 -0
  17. package/src/commands/gc.ts +46 -0
  18. package/src/config/settings-schema.ts +45 -0
  19. package/src/config/settings.ts +44 -6
  20. package/src/edit/hashline/filesystem.ts +11 -6
  21. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  22. package/src/eval/jl/runner.jl +7 -1
  23. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
  24. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +6 -0
  25. package/src/internal-urls/docs-index.generated.txt +1 -1
  26. package/src/lsp/index.ts +8 -1
  27. package/src/mcp/oauth-discovery.ts +20 -20
  28. package/src/mcp/transports/stdio.test.ts +20 -3
  29. package/src/mcp/transports/stdio.ts +45 -14
  30. package/src/modes/controllers/input-controller.ts +2 -2
  31. package/src/modes/controllers/selector-controller.ts +10 -0
  32. package/src/modes/interactive-mode.ts +26 -9
  33. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  34. package/src/modes/theme/theme.ts +33 -14
  35. package/src/prompts/system/plan-mode-active.md +9 -0
  36. package/src/prompts/system/system-prompt.md +2 -0
  37. package/src/sdk.ts +43 -4
  38. package/src/session/agent-session.ts +323 -62
  39. package/src/session/session-listing.ts +35 -2
  40. package/src/slash-commands/builtin-registry.ts +20 -2
  41. package/src/system-prompt.ts +4 -0
  42. package/src/tools/acp-bridge.ts +6 -1
  43. package/src/tools/plan-mode-guard.ts +26 -13
  44. package/src/utils/edit-mode.ts +19 -2
  45. package/src/utils/shell-snapshot.ts +1 -1
@@ -29,6 +29,7 @@ import {
29
29
  type AgentMessage,
30
30
  type AgentState,
31
31
  type AgentTool,
32
+ type AgentTurnEndContext,
32
33
  AppendOnlyContextManager,
33
34
  type AsideMessage,
34
35
  type CompactionSummaryMessage,
@@ -1117,6 +1118,20 @@ function toRestoredQueuedMessage(message: AgentMessage): RestoredQueuedMessage {
1117
1118
  return { text: queueChipText(message), images: queuedImageContent(message) };
1118
1119
  }
1119
1120
 
1121
+ function mergeLlmCompactionPreserveData(
1122
+ hookPreserveData: Record<string, unknown> | undefined,
1123
+ resultPreserveData: Record<string, unknown> | undefined,
1124
+ ): Record<string, unknown> | undefined {
1125
+ const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) };
1126
+ return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined);
1127
+ }
1128
+
1129
+ type MessageEndPersistenceSlot = {
1130
+ readonly promise: Promise<void>;
1131
+ persist: (persistMessage: () => void) => Promise<void>;
1132
+ release: () => void;
1133
+ };
1134
+
1120
1135
  export class AgentSession {
1121
1136
  readonly agent: Agent;
1122
1137
  readonly sessionManager: SessionManager;
@@ -1249,6 +1264,8 @@ export class AgentSession {
1249
1264
  // Extension system
1250
1265
  #extensionRunner: ExtensionRunner | undefined = undefined;
1251
1266
  #turnIndex = 0;
1267
+ #messageEndPersistenceTail: Promise<void> = Promise.resolve();
1268
+ #pendingMessageEndPersistence = new Map<string, Promise<void>>();
1252
1269
 
1253
1270
  #skills: Skill[];
1254
1271
  #skillWarnings: SkillWarning[];
@@ -1614,7 +1631,7 @@ export class AgentSession {
1614
1631
  };
1615
1632
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1616
1633
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1617
- this.agent.setOnTurnEnd(async (messages, signal) => {
1634
+ this.agent.setOnTurnEnd(async (messages, signal, context) => {
1618
1635
  if (signal?.aborted) return;
1619
1636
  const rewindReport = this.#extractRewindReport(messages);
1620
1637
  if (rewindReport) {
@@ -1630,7 +1647,7 @@ export class AgentSession {
1630
1647
  await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1631
1648
  }
1632
1649
  }
1633
- await this.#maintainContextMidRun(messages, signal);
1650
+ await this.#maintainContextMidRun(messages, signal, context);
1634
1651
  });
1635
1652
  this.yieldQueue = new YieldQueue({
1636
1653
  isStreaming: () => this.isStreaming,
@@ -2501,6 +2518,190 @@ export class AgentSession {
2501
2518
  }
2502
2519
  };
2503
2520
 
2521
+ #messageValueSignature(value: unknown): string {
2522
+ return JSON.stringify(value) ?? "undefined";
2523
+ }
2524
+
2525
+ #sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
2526
+ if (left === right) return true;
2527
+ if (left.role !== right.role) return false;
2528
+ switch (left.role) {
2529
+ case "assistant":
2530
+ if (right.role !== "assistant") return false;
2531
+ return (
2532
+ left.timestamp === right.timestamp &&
2533
+ left.provider === right.provider &&
2534
+ left.model === right.model &&
2535
+ left.responseId === right.responseId &&
2536
+ left.stopReason === right.stopReason &&
2537
+ this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2538
+ );
2539
+ case "toolResult":
2540
+ if (right.role !== "toolResult") return false;
2541
+ return (
2542
+ left.timestamp === right.timestamp &&
2543
+ left.toolCallId === right.toolCallId &&
2544
+ left.toolName === right.toolName &&
2545
+ left.isError === right.isError &&
2546
+ this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2547
+ );
2548
+ case "user":
2549
+ if (right.role !== "user") return false;
2550
+ return (
2551
+ left.timestamp === right.timestamp &&
2552
+ left.attribution === right.attribution &&
2553
+ this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2554
+ );
2555
+ case "developer":
2556
+ if (right.role !== "developer") return false;
2557
+ return (
2558
+ left.timestamp === right.timestamp &&
2559
+ left.attribution === right.attribution &&
2560
+ this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
2561
+ );
2562
+ case "fileMention":
2563
+ if (right.role !== "fileMention") return false;
2564
+ return (
2565
+ left.timestamp === right.timestamp &&
2566
+ this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
2567
+ );
2568
+ default:
2569
+ return false;
2570
+ }
2571
+ }
2572
+
2573
+ #sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
2574
+ switch (message.role) {
2575
+ case "assistant":
2576
+ return [
2577
+ "assistant",
2578
+ message.timestamp,
2579
+ message.provider,
2580
+ message.model,
2581
+ message.responseId ?? "",
2582
+ message.stopReason,
2583
+ ].join(":");
2584
+ case "toolResult":
2585
+ return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
2586
+ case "user":
2587
+ case "developer":
2588
+ case "fileMention":
2589
+ return `${message.role}:${message.timestamp}`;
2590
+ default:
2591
+ return undefined;
2592
+ }
2593
+ }
2594
+
2595
+ #createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
2596
+ const key = this.#sessionMessagePersistenceKey(message);
2597
+ if (!key) return undefined;
2598
+ const previous = this.#messageEndPersistenceTail;
2599
+ const { promise, resolve } = Promise.withResolvers<void>();
2600
+ const clear = () => {
2601
+ if (this.#pendingMessageEndPersistence.get(key) === promise) {
2602
+ this.#pendingMessageEndPersistence.delete(key);
2603
+ }
2604
+ };
2605
+ this.#pendingMessageEndPersistence.set(key, promise);
2606
+ this.#messageEndPersistenceTail = promise.catch(() => {});
2607
+ return {
2608
+ promise,
2609
+ persist: async persistMessage => {
2610
+ await previous;
2611
+ try {
2612
+ persistMessage();
2613
+ } finally {
2614
+ resolve();
2615
+ clear();
2616
+ }
2617
+ },
2618
+ release: () => {
2619
+ resolve();
2620
+ clear();
2621
+ },
2622
+ };
2623
+ }
2624
+
2625
+ async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
2626
+ const key = this.#sessionMessagePersistenceKey(message);
2627
+ if (!key) return;
2628
+ await this.#pendingMessageEndPersistence.get(key);
2629
+ }
2630
+
2631
+ #sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
2632
+ const branch = this.sessionManager.getBranch();
2633
+ for (let index = branch.length - 1; index >= 0; index--) {
2634
+ const entry = branch[index];
2635
+ if (entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message)) return true;
2636
+ }
2637
+ return false;
2638
+ }
2639
+
2640
+ #hasPersistedLaterTurnMessage(turnMessages: AgentMessage[], messageIndex: number): boolean {
2641
+ const branch = this.sessionManager.getBranch();
2642
+ for (let index = messageIndex + 1; index < turnMessages.length; index++) {
2643
+ const message = turnMessages[index];
2644
+ if (
2645
+ branch.some(
2646
+ entry => entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message),
2647
+ )
2648
+ ) {
2649
+ return true;
2650
+ }
2651
+ }
2652
+ return false;
2653
+ }
2654
+
2655
+ #persistSessionMessageIfMissing(message: AgentMessage): void {
2656
+ if (
2657
+ message.role !== "user" &&
2658
+ message.role !== "developer" &&
2659
+ message.role !== "assistant" &&
2660
+ message.role !== "toolResult" &&
2661
+ message.role !== "fileMention"
2662
+ ) {
2663
+ return;
2664
+ }
2665
+ if (this.#sessionMessageAlreadyPersisted(message)) return;
2666
+ if (message.role === "assistant") {
2667
+ const assistantMsg = message as AssistantMessage;
2668
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
2669
+ assistantMsg.contextSnapshot = {
2670
+ promptTokens: calculatePromptTokens(assistantMsg.usage),
2671
+ nonMessageTokens: this.#pendingContextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this),
2672
+ };
2673
+ }
2674
+ }
2675
+ const skipPersistedRewindResult =
2676
+ message.role === "toolResult" &&
2677
+ message.toolName === "rewind" &&
2678
+ this.#rewoundToolResultIds.delete(message.toolCallId);
2679
+ if (!skipPersistedRewindResult) {
2680
+ this.sessionManager.appendMessage(message);
2681
+ }
2682
+ }
2683
+
2684
+ async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean> {
2685
+ if (!context) return true;
2686
+ const turnMessages = [context.message, ...context.toolResults];
2687
+ for (const message of turnMessages) {
2688
+ await this.#waitForSessionMessagePersistence(message);
2689
+ }
2690
+ for (let index = 0; index < turnMessages.length; index++) {
2691
+ const message = turnMessages[index];
2692
+ if (this.#sessionMessageAlreadyPersisted(message)) continue;
2693
+ if (this.#hasPersistedLaterTurnMessage(turnMessages, index)) {
2694
+ logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
2695
+ role: message.role,
2696
+ timestamp: message.timestamp,
2697
+ });
2698
+ return false;
2699
+ }
2700
+ this.#persistSessionMessageIfMissing(message);
2701
+ }
2702
+ return true;
2703
+ }
2704
+
2504
2705
  #processAgentEvent = async (event: AgentEvent): Promise<void> => {
2505
2706
  // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
2506
2707
  // persisted message BEFORE the obfuscator's display-side copy below.
@@ -2522,6 +2723,9 @@ export class AgentSession {
2522
2723
  this.#planInternalAbortPending = false;
2523
2724
  }
2524
2725
 
2726
+ const messageEndPersistence =
2727
+ event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined;
2728
+
2525
2729
  // Deobfuscate assistant message content for display emission — the LLM echoes back
2526
2730
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
2527
2731
  // values. The original event.message stays obfuscated so the persistence path below
@@ -2547,7 +2751,12 @@ export class AgentSession {
2547
2751
  });
2548
2752
  }
2549
2753
 
2550
- await this.#emitSessionEvent(displayEvent);
2754
+ try {
2755
+ await this.#emitSessionEvent(displayEvent);
2756
+ } catch (error) {
2757
+ messageEndPersistence?.release();
2758
+ throw error;
2759
+ }
2551
2760
 
2552
2761
  if (event.type === "turn_start") {
2553
2762
  this.#resetStreamingEditState();
@@ -2633,43 +2842,28 @@ export class AgentSession {
2633
2842
 
2634
2843
  // Handle session persistence
2635
2844
  if (event.type === "message_end") {
2636
- // Check if this is a hook/custom message
2637
- if (event.message.role === "hookMessage" || event.message.role === "custom") {
2638
- // Persist as CustomMessageEntry
2639
- this.sessionManager.appendCustomMessageEntry(
2640
- event.message.customType,
2641
- event.message.content,
2642
- event.message.display,
2643
- event.message.details,
2644
- event.message.attribution ?? "agent",
2645
- );
2646
- if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2647
- this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
2648
- }
2649
- } else if (
2650
- event.message.role === "user" ||
2651
- event.message.role === "developer" ||
2652
- event.message.role === "assistant" ||
2653
- event.message.role === "toolResult" ||
2654
- event.message.role === "fileMention"
2655
- ) {
2656
- // Regular LLM message - persist as SessionMessageEntry
2657
- if (event.message.role === "assistant") {
2658
- const assistantMsg = event.message as AssistantMessage;
2659
- if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
2660
- assistantMsg.contextSnapshot = {
2661
- promptTokens: calculatePromptTokens(assistantMsg.usage),
2662
- nonMessageTokens: this.#pendingContextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this),
2663
- };
2845
+ const persistMessageEnd = () => {
2846
+ // Check if this is a hook/custom message
2847
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
2848
+ // Persist as CustomMessageEntry
2849
+ this.sessionManager.appendCustomMessageEntry(
2850
+ event.message.customType,
2851
+ event.message.content,
2852
+ event.message.display,
2853
+ event.message.details,
2854
+ event.message.attribution ?? "agent",
2855
+ );
2856
+ if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2857
+ this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
2664
2858
  }
2859
+ } else {
2860
+ this.#persistSessionMessageIfMissing(event.message);
2665
2861
  }
2666
- const skipPersistedRewindResult =
2667
- event.message.role === "toolResult" &&
2668
- event.message.toolName === "rewind" &&
2669
- this.#rewoundToolResultIds.delete(event.message.toolCallId);
2670
- if (!skipPersistedRewindResult) {
2671
- this.sessionManager.appendMessage(event.message);
2672
- }
2862
+ };
2863
+ if (messageEndPersistence) {
2864
+ await messageEndPersistence.persist(persistMessageEnd);
2865
+ } else {
2866
+ persistMessageEnd();
2673
2867
  }
2674
2868
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
2675
2869
 
@@ -5352,11 +5546,48 @@ export class AgentSession {
5352
5546
  }
5353
5547
 
5354
5548
  #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
5355
- if (!this.#obfuscator?.hasSecrets() || !preparation.previousSummary) return preparation;
5356
- // `previousPreserveData` is opaque provider-replay state (e.g. OpenAI remote-compaction
5357
- // `encrypted_content`); rewriting it would corrupt replay, so only the plaintext summary
5358
- // is redacted.
5359
- return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) };
5549
+ if (!this.#obfuscator?.hasSecrets()) return preparation;
5550
+ const previousSummary = this.#obfuscateTextForProvider(preparation.previousSummary);
5551
+ // `compact()` folds the prior snapcompact archive's plaintext into the
5552
+ // summarization prompt on the snapcompact→context-full transition, so the
5553
+ // archive's text regions must be redacted alongside the summary. Only the
5554
+ // `snapcompact` slot's text is rewritten; every other preserveData key —
5555
+ // notably the OpenAI remote-compaction `encrypted_content` replay state — is
5556
+ // opaque provider-replay data and stays byte-identical.
5557
+ const previousPreserveData = this.#obfuscatePreservedArchiveText(preparation.previousPreserveData);
5558
+ if (
5559
+ previousSummary === preparation.previousSummary &&
5560
+ previousPreserveData === preparation.previousPreserveData
5561
+ ) {
5562
+ return preparation;
5563
+ }
5564
+ return { ...preparation, previousSummary, previousPreserveData };
5565
+ }
5566
+
5567
+ /** Redact secrets in the persisted snapcompact archive's plaintext regions
5568
+ * ({@link snapcompact.archiveSourceText}'s `text`/`textHead`/`textTail`) so the
5569
+ * snapcompact→context-full migration in `compact()` cannot ship raw archived
5570
+ * user/tool text to the provider. Frames and every non-`snapcompact` key pass
5571
+ * through byte-identical; the same reference is returned when nothing changes. */
5572
+ #obfuscatePreservedArchiveText(
5573
+ preserveData: Record<string, unknown> | undefined,
5574
+ ): Record<string, unknown> | undefined {
5575
+ const obfuscator = this.#obfuscator;
5576
+ if (!obfuscator?.hasSecrets() || !preserveData || !snapcompact.getPreservedArchive(preserveData)) {
5577
+ return preserveData;
5578
+ }
5579
+ const slot = preserveData[snapcompact.PRESERVE_KEY] as Record<string, unknown>;
5580
+ const obfuscated: Record<string, unknown> = { ...slot };
5581
+ let changed = false;
5582
+ for (const key of ["text", "textHead", "textTail"] as const) {
5583
+ const value = slot[key];
5584
+ if (typeof value !== "string" || value.length === 0) continue;
5585
+ const next = obfuscator.obfuscate(value);
5586
+ if (next === value) continue;
5587
+ obfuscated[key] = next;
5588
+ changed = true;
5589
+ }
5590
+ return changed ? { ...preserveData, [snapcompact.PRESERVE_KEY]: obfuscated } : preserveData;
5360
5591
  }
5361
5592
 
5362
5593
  #deobfuscateFromProvider(text: string): string {
@@ -5695,6 +5926,7 @@ export class AgentSession {
5695
5926
  askToolName: "ask",
5696
5927
  writeToolName: "write",
5697
5928
  editToolName: "edit",
5929
+ isHashlineEditMode: this.#resolveActiveEditMode() === "hashline",
5698
5930
  reentry: state.reentry ?? false,
5699
5931
  iterative: state.workflow === "iterative",
5700
5932
  });
@@ -8020,7 +8252,7 @@ export class AgentSession {
8020
8252
  firstKeptEntryId = result.firstKeptEntryId;
8021
8253
  tokensBefore = result.tokensBefore;
8022
8254
  details = result.details;
8023
- preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
8255
+ preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData);
8024
8256
  } catch (err) {
8025
8257
  if (err instanceof CompactionCancelledError) {
8026
8258
  throw err;
@@ -8398,32 +8630,50 @@ export class AgentSession {
8398
8630
  }
8399
8631
 
8400
8632
  /**
8401
- * Compact active `/goal` runs that never settle to `agent_end`.
8633
+ * Compact continuing tool-loop runs before the next provider request.
8402
8634
  *
8403
- * Long autonomous goals can keep producing tool calls inside one agent run.
8404
- * The post-turn `agent_end` threshold check never fires in that shape, so
8405
- * context can grow until provider overflow. `onTurnEnd` is the safe boundary:
8406
- * tool results for the just-finished turn are already paired in
8407
- * `activeMessages`, the live array the agent loop reads before its next
8408
- * model call. Run maintenance here and splice the compacted state back into
8409
- * that array, mirroring [`AgentSession.#applyRewind`].
8410
- */
8411
- async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
8412
- if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
8413
- if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
8635
+ * `onTurnEnd` is the safe boundary: tool results for the just-finished turn
8636
+ * are already paired in `activeMessages`, the live array the agent loop reads
8637
+ * before its next model call. Before compacting, the just-finished turn is
8638
+ * synchronously persisted if async message hooks have not reached the normal
8639
+ * append path yet. Mid-run handoff is suppressed because resetting the session
8640
+ * while the loop owns `activeMessages` would race the next request; handoff
8641
+ * strategy falls back to in-place context-full compaction here.
8642
+ */
8643
+ async #maintainContextMidRun(
8644
+ activeMessages: AgentMessage[],
8645
+ signal: AbortSignal | undefined,
8646
+ context: AgentTurnEndContext | undefined,
8647
+ ): Promise<void> {
8648
+ if (
8649
+ signal?.aborted ||
8650
+ this.#isDisposed ||
8651
+ this.isCompacting ||
8652
+ this.isGeneratingHandoff ||
8653
+ !context?.willContinue
8654
+ )
8655
+ return;
8414
8656
 
8415
8657
  const model = this.model;
8416
8658
  const contextWindow = model?.contextWindow ?? 0;
8417
8659
  if (contextWindow <= 0) return;
8418
8660
 
8419
8661
  const compactionSettings = this.settings.getGroup("compaction");
8420
- if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
8662
+ if (
8663
+ !compactionSettings.enabled ||
8664
+ compactionSettings.strategy === "off" ||
8665
+ compactionSettings.midTurnEnabled === false
8666
+ ) {
8667
+ return;
8668
+ }
8421
8669
 
8422
8670
  const lastAssistant = [...activeMessages]
8423
8671
  .reverse()
8424
8672
  .find((message): message is AssistantMessage => message.role === "assistant");
8425
8673
  if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
8426
8674
 
8675
+ if (!(await this.#persistTurnMessagesForMidRunCompaction(context))) return;
8676
+
8427
8677
  const billedContextTokens = calculateContextTokens(lastAssistant.usage);
8428
8678
  const storedContextTokens = this.#estimateStoredContextTokens();
8429
8679
  const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
@@ -8433,6 +8683,7 @@ export class AgentSession {
8433
8683
  await this.#runAutoCompaction("threshold", false, false, false, {
8434
8684
  autoContinue: false,
8435
8685
  suppressContinuation: true,
8686
+ suppressHandoff: true,
8436
8687
  triggerContextTokens: contextTokens,
8437
8688
  });
8438
8689
 
@@ -8441,10 +8692,11 @@ export class AgentSession {
8441
8692
  if (compactedMessages !== activeMessages) {
8442
8693
  activeMessages.splice(0, activeMessages.length, ...compactedMessages);
8443
8694
  }
8444
- logger.debug("Mid-run goal compaction ran between tool-call turns", {
8695
+ logger.debug("Mid-run compaction ran between provider calls", {
8445
8696
  contextTokens,
8446
8697
  contextWindow,
8447
8698
  strategy: compactionSettings.strategy,
8699
+ goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active",
8448
8700
  messagesBefore,
8449
8701
  messagesAfter: activeMessages.length,
8450
8702
  });
@@ -9839,7 +10091,12 @@ export class AgentSession {
9839
10091
  willRetry: boolean,
9840
10092
  deferred = false,
9841
10093
  allowDefer = true,
9842
- options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
10094
+ options: {
10095
+ autoContinue?: boolean;
10096
+ triggerContextTokens?: number;
10097
+ suppressContinuation?: boolean;
10098
+ suppressHandoff?: boolean;
10099
+ } = {},
9843
10100
  ): Promise<CompactionCheckResult> {
9844
10101
  const compactionSettings = this.settings.getGroup("compaction");
9845
10102
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
@@ -9848,6 +10105,7 @@ export class AgentSession {
9848
10105
  const suppressContinuation = options.suppressContinuation === true;
9849
10106
  const shouldAutoContinue =
9850
10107
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
10108
+ const suppressHandoff = options.suppressHandoff === true;
9851
10109
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
9852
10110
  // reclaims nothing we fall through to the summary-compaction body below so
9853
10111
  // the oversized input still gets resolved.
@@ -9866,6 +10124,7 @@ export class AgentSession {
9866
10124
  // paths the caller wants resolved before scheduling the next turn. "idle" is
9867
10125
  // triggered by the idle loop and does its own scheduling.
9868
10126
  if (
10127
+ !suppressHandoff &&
9869
10128
  !deferred &&
9870
10129
  allowDefer &&
9871
10130
  reason !== "overflow" &&
@@ -9890,7 +10149,9 @@ export class AgentSession {
9890
10149
  // safe for every reason (it makes no LLM call at all) but requires a vision
9891
10150
  // model to be worth anything — fall back to context-full otherwise.
9892
10151
  let action: "context-full" | "handoff" | "snapcompact" =
9893
- compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
10152
+ compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10153
+ ? "handoff"
10154
+ : "context-full";
9894
10155
  if (compactionSettings.strategy === "snapcompact") {
9895
10156
  if (this.model?.input.includes("image")) {
9896
10157
  action = "snapcompact";
@@ -9917,7 +10178,7 @@ export class AgentSession {
9917
10178
  // a message typed as the compaction loader appears must land in the compaction
9918
10179
  // queue, not the core steering queue (which handoff's agent.reset() would wipe).
9919
10180
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9920
- if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
10181
+ if (action === "handoff") {
9921
10182
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
9922
10183
  const handoffResult = await this.handoff(handoffFocus, {
9923
10184
  autoTriggered: true,
@@ -10246,7 +10507,7 @@ export class AgentSession {
10246
10507
  firstKeptEntryId = compactResult.firstKeptEntryId;
10247
10508
  tokensBefore = compactResult.tokensBefore;
10248
10509
  details = compactResult.details;
10249
- preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
10510
+ preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData);
10250
10511
  }
10251
10512
 
10252
10513
  if (autoCompactionSignal.aborted) {
@@ -495,6 +495,19 @@ async function scanSessionDir(
495
495
  }
496
496
  }
497
497
 
498
+ async function scanSessionDirReadOnly(
499
+ sessionDir: string,
500
+ storage: SessionStorage,
501
+ withStatus: boolean,
502
+ ): Promise<SessionInfo[]> {
503
+ try {
504
+ const files = storage.listFilesSync(sessionDir, "*.jsonl");
505
+ return await collectSessionsFromFiles(files, storage, withStatus);
506
+ } catch {
507
+ return [];
508
+ }
509
+ }
510
+
498
511
  /**
499
512
  * List sessions in a resolved session directory (newest first), reading each
500
513
  * file's lifecycle {@link SessionStatus}.
@@ -503,6 +516,13 @@ export function listSessions(sessionDir: string, storage: SessionStorage): Promi
503
516
  return scanSessionDir(sessionDir, storage, true);
504
517
  }
505
518
 
519
+ /**
520
+ * List sessions without repairing orphaned backups or mutating the directory.
521
+ */
522
+ export function listSessionsReadOnly(sessionDir: string, storage: SessionStorage): Promise<SessionInfo[]> {
523
+ return scanSessionDirReadOnly(sessionDir, storage, true);
524
+ }
525
+
506
526
  /** List all sessions across all project directories (newest first). */
507
527
  export async function listAllSessions(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
508
528
  const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
@@ -561,12 +581,25 @@ function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): bool
561
581
  return fileSessionId.startsWith(normalizedArg);
562
582
  }
563
583
 
584
+ /** Controls cross-directory fallback for resumable session lookup. */
585
+ export interface ResolveResumableSessionOptions {
586
+ /** Search default global session buckets after the active/custom session directory misses. */
587
+ allowGlobalFallback?: boolean;
588
+ }
589
+
590
+ function isSessionStorage(value: SessionStorage | ResolveResumableSessionOptions): value is SessionStorage {
591
+ return "listFilesSync" in value;
592
+ }
593
+
564
594
  export async function resolveResumableSession(
565
595
  sessionArg: string,
566
596
  cwd: string,
567
597
  sessionDir?: string,
568
- storage: SessionStorage = new FileSessionStorage(),
598
+ storageOrOptions: SessionStorage | ResolveResumableSessionOptions = new FileSessionStorage(),
599
+ options: ResolveResumableSessionOptions = {},
569
600
  ): Promise<ResolvedSessionMatch | undefined> {
601
+ const storage = isSessionStorage(storageOrOptions) ? storageOrOptions : new FileSessionStorage();
602
+ const resolvedOptions = isSessionStorage(storageOrOptions) ? options : storageOrOptions;
570
603
  const localSessionDir = sessionDir ?? computeDefaultSessionDir(cwd, storage);
571
604
  const localSessions = await listSessions(localSessionDir, storage);
572
605
  const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
@@ -574,7 +607,7 @@ export async function resolveResumableSession(
574
607
  return { session: localMatch, scope: "local" };
575
608
  }
576
609
 
577
- if (sessionDir) {
610
+ if (sessionDir && resolvedOptions.allowGlobalFallback !== true) {
578
611
  return undefined;
579
612
  }
580
613
 
@@ -27,6 +27,7 @@ import { theme } from "../modes/theme/theme";
27
27
  import type { InteractiveModeContext } from "../modes/types";
28
28
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
29
29
  import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
30
+ import { resolveResumableSession } from "../session/session-listing";
30
31
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
31
32
  import { urlHyperlinkAlways } from "../tui";
32
33
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
@@ -1414,9 +1415,26 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1414
1415
  {
1415
1416
  name: "resume",
1416
1417
  description: "Resume a different session",
1417
- handleTui: (_command, runtime) => {
1418
- runtime.ctx.showSessionSelector();
1418
+ inlineHint: "[session id]",
1419
+ allowArgs: true,
1420
+ handleTui: async (command, runtime) => {
1421
+ const sessionArg = command.args.trim();
1419
1422
  runtime.ctx.editor.setText("");
1423
+ if (!sessionArg) {
1424
+ runtime.ctx.showSessionSelector();
1425
+ return;
1426
+ }
1427
+ const match = await resolveResumableSession(
1428
+ sessionArg,
1429
+ runtime.ctx.sessionManager.getCwd(),
1430
+ runtime.ctx.sessionManager.getSessionDir(),
1431
+ { allowGlobalFallback: true },
1432
+ );
1433
+ if (!match) {
1434
+ runtime.ctx.showError(`Session "${sessionArg}" not found`);
1435
+ return;
1436
+ }
1437
+ await runtime.ctx.handleResumeSession(match.session.path);
1420
1438
  },
1421
1439
  },
1422
1440
  {
@@ -423,6 +423,8 @@ export interface BuildSystemPromptOptions {
423
423
  personality?: Personality;
424
424
  /** Whether to include the workspace directory tree in the system prompt. Default: false */
425
425
  includeWorkspaceTree?: boolean;
426
+ /** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
427
+ renderMermaid?: boolean;
426
428
  }
427
429
 
428
430
  /** Result of building provider-facing system prompt messages. */
@@ -464,6 +466,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
464
466
  model,
465
467
  personality = "default",
466
468
  includeWorkspaceTree = false,
469
+ renderMermaid = true,
467
470
  } = options;
468
471
  const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
469
472
  const resolvedCwd = cwd ?? getProjectDir();
@@ -682,6 +685,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
682
685
  hasMemoryRoot: memoryRootEnabled,
683
686
  hasObsidian: hasObsidian(),
684
687
  includeWorkspaceTree,
688
+ renderMermaid,
685
689
  };
686
690
  const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
687
691
  const systemPrompt = [rendered];