@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16

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 (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. package/src/web/search/providers/perplexity.ts +18 -6
@@ -55,8 +55,9 @@ import {
55
55
  effectiveReserveTokens,
56
56
  estimateTokens,
57
57
  generateBranchSummary,
58
- generateHandoff,
58
+ generateHandoffFromContext,
59
59
  prepareCompaction,
60
+ renderHandoffPrompt,
60
61
  resolveThresholdTokens,
61
62
  type SessionEntry,
62
63
  type SessionMessageEntry,
@@ -103,7 +104,7 @@ import {
103
104
  resolveServiceTier,
104
105
  streamSimple,
105
106
  } from "@oh-my-pi/pi-ai";
106
- import { stripToolDescriptions, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
+ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
108
  import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
108
109
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
109
110
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -1619,6 +1620,7 @@ export class AgentSession {
1619
1620
  await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1620
1621
  }
1621
1622
  }
1623
+ await this.#maintainContextMidRun(messages, signal);
1622
1624
  });
1623
1625
  this.yieldQueue = new YieldQueue({
1624
1626
  isStreaming: () => this.isStreaming,
@@ -1936,6 +1938,7 @@ export class AgentSession {
1936
1938
  snapshotMessages: () => this.agent.state.messages,
1937
1939
  enqueueAdvice,
1938
1940
  maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
1941
+ obfuscator: this.#obfuscator,
1939
1942
  });
1940
1943
  if (seedToCurrent) {
1941
1944
  this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -2776,10 +2779,32 @@ export class AgentSession {
2776
2779
  this.#lastAssistantMessage = undefined;
2777
2780
  if (!msg) {
2778
2781
  this.#lastSuccessfulYieldToolCallId = undefined;
2782
+ logger.debug("agent_end maintenance routing", {
2783
+ reason: "no-assistant-message",
2784
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2785
+ goalStatus: this.#goalModeState?.goal.status,
2786
+ });
2779
2787
  await emitAgentEndNotification();
2780
2788
  return;
2781
2789
  }
2782
2790
 
2791
+ const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
2792
+ logger.debug("agent_end maintenance routing", {
2793
+ route,
2794
+ stopReason: msg.stopReason,
2795
+ provider: msg.provider,
2796
+ model: msg.model,
2797
+ contentBlocks: msg.content.length,
2798
+ hasToolCalls: msg.content.some(content => content.type === "toolCall"),
2799
+ hasText: msg.content.some(content => content.type === "text"),
2800
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2801
+ goalStatus: this.#goalModeState?.goal.status,
2802
+ successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
2803
+ ...extra,
2804
+ });
2805
+ };
2806
+ maintenanceRoute("entered");
2807
+
2783
2808
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
2784
2809
  // aren't reused on the next request
2785
2810
  if (
@@ -2793,27 +2818,61 @@ export class AgentSession {
2793
2818
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
2794
2819
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
2795
2820
  this.#lastSuccessfulYieldToolCallId = undefined;
2821
+ maintenanceRoute("skip-post-turn-maintenance");
2796
2822
  await emitAgentEndNotification();
2797
2823
  return;
2798
2824
  }
2799
2825
 
2826
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2800
2827
  if (this.#assistantEndedWithSuccessfulYield(msg)) {
2801
2828
  this.#lastSuccessfulYieldToolCallId = undefined;
2802
- if (this.#goalModeState?.enabled && this.#goalModeState.goal.status === "active") {
2829
+ if (activeGoal) {
2830
+ maintenanceRoute("successful-yield-active-goal-checkCompaction");
2803
2831
  const compactionTask = this.#checkCompaction(msg);
2804
2832
  this.#trackPostPromptTask(compactionTask);
2805
2833
  await compactionTask;
2834
+ } else {
2835
+ maintenanceRoute("successful-yield-no-active-goal");
2806
2836
  }
2807
2837
  await emitAgentEndNotification();
2808
2838
  return;
2809
2839
  }
2810
2840
  this.#lastSuccessfulYieldToolCallId = undefined;
2811
2841
 
2842
+ // Empty-stop cleanup MUST run before any compaction continuation: an
2843
+ // empty toolUse stop must be stripped from active context + session
2844
+ // history before we schedule another turn, otherwise the next
2845
+ // Anthropic turn carries a tool_use block with no matching
2846
+ // tool_result and corrupts message history. The handler also
2847
+ // schedules its own retry, so a real empty stop never needs the
2848
+ // active-goal threshold pre-empt below.
2812
2849
  if (await this.#handleEmptyAssistantStop(msg)) {
2850
+ maintenanceRoute("empty-stop-handled");
2813
2851
  await emitAgentEndNotification();
2814
2852
  return;
2815
2853
  }
2854
+
2855
+ let compactionResult = COMPACTION_CHECK_NONE;
2856
+ let checkedCompaction = false;
2857
+ if (activeGoal) {
2858
+ maintenanceRoute("active-goal-pre-empt-checkCompaction");
2859
+ const compactionTask = this.#checkCompaction(msg);
2860
+ this.#trackPostPromptTask(compactionTask);
2861
+ compactionResult = await compactionTask;
2862
+ checkedCompaction = true;
2863
+ if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
2864
+ maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
2865
+ deferredHandoff: compactionResult.deferredHandoff,
2866
+ continuationScheduled: compactionResult.continuationScheduled,
2867
+ });
2868
+ this.#resolveRetry();
2869
+ await emitAgentEndNotification();
2870
+ return;
2871
+ }
2872
+ }
2873
+
2816
2874
  if (await this.#handleUnexpectedAssistantStop(msg)) {
2875
+ maintenanceRoute("unexpected-stop-handled");
2817
2876
  await emitAgentEndNotification();
2818
2877
  return;
2819
2878
  }
@@ -2853,9 +2912,12 @@ export class AgentSession {
2853
2912
  }
2854
2913
  this.#resolveRetry();
2855
2914
 
2856
- const compactionTask = this.#checkCompaction(msg);
2857
- this.#trackPostPromptTask(compactionTask);
2858
- const compactionResult = await compactionTask;
2915
+ if (!checkedCompaction) {
2916
+ maintenanceRoute("bottom-checkCompaction");
2917
+ const compactionTask = this.#checkCompaction(msg);
2918
+ this.#trackPostPromptTask(compactionTask);
2919
+ compactionResult = await compactionTask;
2920
+ }
2859
2921
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
2860
2922
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
2861
2923
  if (hasToolCalls) {
@@ -8048,27 +8110,60 @@ export class AgentSession {
8048
8110
  throw new Error(`No API key for ${model.provider}`);
8049
8111
  }
8050
8112
 
8051
- const rawHandoffText = await generateHandoff(
8052
- this.agent.state.messages,
8053
- model,
8054
- this.#modelRegistry.resolver(model, this.sessionId),
8113
+ // Build the handoff request through the SAME pipeline a live turn uses
8114
+ // (`runEphemeralTurn` / `/btw` share it) so the oneshot reads the
8115
+ // provider prompt cache the main turn populated instead of cold-missing
8116
+ // the whole prefix: identical system prompt, normalized tools, and
8117
+ // transform-/obfuscation-matched message history via
8118
+ // `convertMessagesToLlm` + `buildSideRequestContext`, plus the live turn's
8119
+ // effective provider cache key with a unique side `sessionId` so
8120
+ // OpenAI/Codex append-only state never mixes with the live turn.
8121
+ const cacheSessionId = this.sessionId;
8122
+ // The loop sends `promptCacheKey` (providerPromptCacheKey) and falls back to
8123
+ // the provider session id; providers route on `promptCacheKey ?? sessionId`.
8124
+ // Both can diverge from this.sessionId (tan/subagent/shared sessions), so
8125
+ // mirror exactly what the live turn populated the cache under.
8126
+ const handoffPromptCacheKey = this.agent.promptCacheKey ?? this.agent.sessionId;
8127
+ const handoffPromptText = renderHandoffPrompt(this.#obfuscateTextForProvider(customInstructions));
8128
+ const handoffSnapshot: AgentMessage[] = [
8129
+ ...this.agent.state.messages,
8055
8130
  {
8056
- systemPrompt: this.#baseSystemPrompt,
8057
- tools: this.#pruneToolDescriptions
8058
- ? stripToolDescriptions(this.agent.state.tools)
8059
- : this.agent.state.tools,
8060
- customInstructions: this.#obfuscateTextForProvider(customInstructions),
8061
- convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
8131
+ role: "user",
8132
+ content: [{ type: "text", text: handoffPromptText }],
8133
+ attribution: "agent",
8134
+ timestamp: Date.now(),
8135
+ },
8136
+ ];
8137
+ const handoffLlmMessages = await this.convertMessagesToLlm(handoffSnapshot, handoffSignal);
8138
+ // Base system prompt, not a per-turn `before_agent_start` hook override —
8139
+ // the handoff seeds a fresh session and must not carry prompt-specific
8140
+ // hook state. Matches the prompt the old handoff path sent.
8141
+ const handoffContext = await this.agent.buildSideRequestContext(handoffLlmMessages, this.#baseSystemPrompt);
8142
+ const handoffStreamOptions = this.prepareSimpleStreamOptions(
8143
+ {
8144
+ apiKey: this.#modelRegistry.resolver(model, cacheSessionId),
8145
+ sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
8146
+ promptCacheKey: handoffPromptCacheKey,
8147
+ preferWebsockets: false,
8148
+ serviceTier: this.#effectiveServiceTier(model),
8149
+ hideThinkingSummary: this.agent.hideThinkingSummary,
8062
8150
  initiatorOverride: "agent",
8063
- metadata: this.agent.metadataForProvider(model.provider),
8151
+ signal: handoffSignal,
8152
+ },
8153
+ model.provider,
8154
+ );
8155
+ const rawHandoffText = await generateHandoffFromContext(
8156
+ obfuscateProviderContext(this.#obfuscator, handoffContext),
8157
+ model,
8158
+ {
8159
+ streamOptions: handoffStreamOptions,
8064
8160
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
8065
- // Honor the user's /model thinking selection on the handoff
8066
- // path. Clamped per-model inside generateHandoff via
8067
- // resolveCompactionEffort so unsupported-effort models don't
8068
- // trip requireSupportedEffort.
8161
+ // Honor the user's /model thinking selection on the handoff path.
8162
+ // Clamped per-model inside generateHandoffFromContext via
8163
+ // resolveCompactionEffort so unsupported-effort models don't trip
8164
+ // requireSupportedEffort.
8069
8165
  thinkingLevel: this.thinkingLevel,
8070
8166
  },
8071
- handoffSignal,
8072
8167
  );
8073
8168
  const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
8074
8169
 
@@ -8212,6 +8307,58 @@ export class AgentSession {
8212
8307
  });
8213
8308
  }
8214
8309
 
8310
+ /**
8311
+ * Compact active `/goal` runs that never settle to `agent_end`.
8312
+ *
8313
+ * Long autonomous goals can keep producing tool calls inside one agent run.
8314
+ * The post-turn `agent_end` threshold check never fires in that shape, so
8315
+ * context can grow until provider overflow. `onTurnEnd` is the safe boundary:
8316
+ * tool results for the just-finished turn are already paired in
8317
+ * `activeMessages`, the live array the agent loop reads before its next
8318
+ * model call. Run maintenance here and splice the compacted state back into
8319
+ * that array, mirroring [`AgentSession.#applyRewind`].
8320
+ */
8321
+ async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
8322
+ if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
8323
+ if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
8324
+
8325
+ const model = this.model;
8326
+ const contextWindow = model?.contextWindow ?? 0;
8327
+ if (contextWindow <= 0) return;
8328
+
8329
+ const compactionSettings = this.settings.getGroup("compaction");
8330
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
8331
+
8332
+ const lastAssistant = [...activeMessages]
8333
+ .reverse()
8334
+ .find((message): message is AssistantMessage => message.role === "assistant");
8335
+ if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
8336
+
8337
+ const billedContextTokens = calculateContextTokens(lastAssistant.usage);
8338
+ const storedContextTokens = this.#estimateStoredContextTokens();
8339
+ const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
8340
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
8341
+
8342
+ const messagesBefore = activeMessages.length;
8343
+ await this.#runAutoCompaction("threshold", false, false, false, {
8344
+ autoContinue: false,
8345
+ suppressContinuation: true,
8346
+ triggerContextTokens: contextTokens,
8347
+ });
8348
+
8349
+ if (signal?.aborted) return;
8350
+ const compactedMessages = this.agent.state.messages;
8351
+ if (compactedMessages !== activeMessages) {
8352
+ activeMessages.splice(0, activeMessages.length, ...compactedMessages);
8353
+ }
8354
+ logger.debug("Mid-run goal compaction ran between tool-call turns", {
8355
+ contextTokens,
8356
+ contextWindow,
8357
+ strategy: compactionSettings.strategy,
8358
+ messagesBefore,
8359
+ messagesAfter: activeMessages.length,
8360
+ });
8361
+ }
8215
8362
  /**
8216
8363
  * Check if context maintenance or promotion is needed and run it.
8217
8364
  * Called after agent_end and before prompt submission.
@@ -8337,28 +8484,58 @@ export class AgentSession {
8337
8484
  // Skip if this was an error (non-overflow errors don't have usage data)
8338
8485
  if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
8339
8486
  const pruneResult = await this.#pruneToolOutputs();
8340
- let contextTokens = calculateContextTokens(assistantMessage.usage);
8341
- if (supersedeResult) {
8342
- contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
8343
- }
8344
- if (pruneResult) {
8345
- contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
8346
- }
8347
- // Floor by the real stored-conversation estimate so a payload-shrinking
8348
- // before_provider_request hook (e.g. a compression extension such as
8349
- // Headroom) can't deflate the provider-reported usage below the true
8350
- // history size and skip the threshold. The estimate runs after the prune
8351
- // passes above, so it reflects the post-prune message set.
8352
- contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
8353
- if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
8487
+ const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
8488
+ const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
8489
+ const storedContextTokens = this.#estimateStoredContextTokens();
8490
+ // Pruning frees bytes for the NEXT prompt; it does not change the size of
8491
+ // the prompt the LLM just billed for. Earlier revisions subtracted the
8492
+ // per-turn supersede/prune `tokensSaved` from the threshold input, which
8493
+ // let a long-running `/goal` session sit above `compaction.thresholdTokens`
8494
+ // indefinitely whenever per-turn pruning saved enough to drop the
8495
+ // post-prune estimate below the user-configured trigger the visible
8496
+ // context (anchored to the same provider billing) still showed >threshold,
8497
+ // but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
8498
+ // last turn's billed context tokens, floored by the post-prune
8499
+ // stored-conversation estimate so a payload-compression hook still can't
8500
+ // deflate the trigger.
8501
+ const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
8502
+ const postMaintenanceContextTokens = compactionContextTokens(
8503
+ Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
8504
+ storedContextTokens,
8505
+ );
8506
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
8507
+ const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
8508
+ logger.debug("Auto-compaction threshold decision", {
8509
+ phase: "post-agent-end",
8510
+ goalModeEnabled: this.#goalModeState?.enabled === true,
8511
+ goalStatus: this.#goalModeState?.goal.status,
8512
+ stopReason: assistantMessage.stopReason,
8513
+ sameModel: sameModel === true,
8514
+ contextWindow,
8515
+ strategy: compactionSettings.strategy,
8516
+ thresholdTokens,
8517
+ assistantUsageContextTokens,
8518
+ storedContextTokens,
8519
+ resolvedContextTokens: contextTokens,
8520
+ postMaintenanceContextTokens,
8521
+ maintenanceTokensFreed,
8522
+ shouldCompact: shouldThresholdCompact,
8523
+ contextPromotionEnabled: this.settings.get("contextPromotion.enabled") === true,
8524
+ });
8525
+ if (shouldThresholdCompact) {
8354
8526
  // Try promotion first — if a larger model is available, switch instead of compacting
8355
8527
  const promoted = await this.#tryContextPromotion(assistantMessage);
8356
8528
  if (!promoted) {
8357
8529
  return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
8358
8530
  autoContinue,
8359
- triggerContextTokens: contextTokens,
8531
+ triggerContextTokens: postMaintenanceContextTokens,
8360
8532
  });
8361
8533
  }
8534
+ logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
8535
+ contextTokens,
8536
+ contextWindow,
8537
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
8538
+ });
8362
8539
  }
8363
8540
  return COMPACTION_CHECK_NONE;
8364
8541
  }
@@ -9026,6 +9203,21 @@ export class AgentSession {
9026
9203
  providerKeys.add(`openai-responses:${nextModel.provider}`);
9027
9204
  }
9028
9205
 
9206
+ // `openai-completions` sessions are keyed `openai-completions:<provider>:<resolvedBaseUrl>:<modelId>`
9207
+ // and cache backend-specific decisions (strict-tools disable scopes, reasoning-effort
9208
+ // fallbacks). The resolved request base URL can differ from the catalog `model.baseUrl`
9209
+ // (Moonshot env override, Alibaba Coding Plan enterprise URL, Azure deployment URL),
9210
+ // so evict by provider prefix when the user moves away from that completions backend.
9211
+ let completionsPrefixToEvict: string | undefined;
9212
+ if (currentModel.api === "openai-completions") {
9213
+ const currentScope = `${currentModel.provider}:${currentModel.baseUrl ?? ""}`;
9214
+ const nextScope =
9215
+ nextModel.api === "openai-completions" ? `${nextModel.provider}:${nextModel.baseUrl ?? ""}` : undefined;
9216
+ if (currentScope !== nextScope) {
9217
+ completionsPrefixToEvict = `openai-completions:${currentModel.provider}:`;
9218
+ }
9219
+ }
9220
+
9029
9221
  for (const providerKey of providerKeys) {
9030
9222
  const state = this.#providerSessionState.get(providerKey);
9031
9223
  if (!state) continue;
@@ -9041,6 +9233,21 @@ export class AgentSession {
9041
9233
 
9042
9234
  this.#providerSessionState.delete(providerKey);
9043
9235
  }
9236
+
9237
+ if (completionsPrefixToEvict !== undefined) {
9238
+ for (const [key, state] of this.#providerSessionState) {
9239
+ if (!key.startsWith(completionsPrefixToEvict)) continue;
9240
+ try {
9241
+ state.close();
9242
+ } catch (error) {
9243
+ logger.warn("Failed to close provider session state during model switch", {
9244
+ providerKey: key,
9245
+ error: String(error),
9246
+ });
9247
+ }
9248
+ this.#providerSessionState.delete(key);
9249
+ }
9250
+ }
9044
9251
  }
9045
9252
 
9046
9253
  #normalizeProviderReplayValue(value: unknown): unknown {
@@ -9452,13 +9659,15 @@ export class AgentSession {
9452
9659
  willRetry: boolean,
9453
9660
  deferred = false,
9454
9661
  allowDefer = true,
9455
- options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
9662
+ options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
9456
9663
  ): Promise<CompactionCheckResult> {
9457
9664
  const compactionSettings = this.settings.getGroup("compaction");
9458
9665
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
9459
9666
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
9460
9667
  const generation = this.#promptGeneration;
9461
- const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
9668
+ const suppressContinuation = options.suppressContinuation === true;
9669
+ const shouldAutoContinue =
9670
+ !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
9462
9671
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
9463
9672
  // reclaims nothing we fall through to the summary-compaction body below so
9464
9673
  // the oversized input still gets resolved.
@@ -9469,6 +9678,7 @@ export class AgentSession {
9469
9678
  generation,
9470
9679
  shouldAutoContinue,
9471
9680
  options.triggerContextTokens,
9681
+ suppressContinuation,
9472
9682
  );
9473
9683
  if (outcome !== "fallback") return outcome;
9474
9684
  }
@@ -9922,7 +10132,7 @@ export class AgentSession {
9922
10132
 
9923
10133
  this.#scheduleAgentContinue({ delayMs: 100, generation });
9924
10134
  continuationScheduled = true;
9925
- } else if (this.agent.hasQueuedMessages()) {
10135
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
9926
10136
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
9927
10137
  // Kick the loop so queued messages are actually delivered.
9928
10138
  this.#scheduleAgentContinue({
@@ -9982,6 +10192,7 @@ export class AgentSession {
9982
10192
  generation: number,
9983
10193
  autoContinue: boolean,
9984
10194
  triggerContextTokens?: number,
10195
+ suppressContinuation = false,
9985
10196
  ): Promise<CompactionCheckResult | "fallback"> {
9986
10197
  const action = "shake";
9987
10198
  this.#autoCompactionAbortController?.abort();
@@ -10012,15 +10223,16 @@ export class AgentSession {
10012
10223
  // situation actually resolves; "idle" is exempt because its 60s+ timer
10013
10224
  // re-checks usage before re-firing and cannot dead-loop on its own.
10014
10225
  //
10015
- // #2275: the post-shake check MUST be anchored on the same metric that
10016
- // triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
10017
- // undercounts thinking-signature payloads, so on thinking-heavy sessions it
10018
- // reads well below the provider-reported usage that fired the threshold.
10019
- // When that estimate slips under the threshold, the fallback never fires
10020
- // and the auto-continue prompt re-injects every turn. Prefer the trigger's
10021
- // own `contextTokens` (provider-anchored) when the caller supplies it, and
10022
- // add hysteresis (80% recovery band) so we don't oscillate at the boundary
10023
- // while shake keeps reclaiming a trickle of the previous turn's output.
10226
+ // #2275: the post-shake check MUST stay provider-anchored when caller
10227
+ // usage and local estimates diverge. The local estimator undercounts
10228
+ // thinking-signature payloads, so thinking-heavy sessions can read well
10229
+ // below the provider usage that fired the threshold. Prefer the caller's
10230
+ // context figure when supplied, then subtract shake's own savings and add
10231
+ // hysteresis (80% recovery band) so we don't oscillate at the boundary.
10232
+ // Threshold callers pass the provider-billed trigger after accounting for
10233
+ // any supersede/drop-useless pruning that already rewrote the next prompt;
10234
+ // without that pre-shake savings, shake can fall through to context-full
10235
+ // even though the post-prune history is already inside the recovery band.
10024
10236
  const contextWindow = this.model?.contextWindow ?? 0;
10025
10237
  const compactionSettings = this.settings.getGroup("compaction");
10026
10238
  let stillOverThreshold = false;
@@ -10080,7 +10292,7 @@ export class AgentSession {
10080
10292
  }
10081
10293
  this.#scheduleAgentContinue({ delayMs: 100, generation });
10082
10294
  continuationScheduled = true;
10083
- } else if (this.agent.hasQueuedMessages()) {
10295
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
10084
10296
  this.#scheduleAgentContinue({
10085
10297
  delayMs: 100,
10086
10298
  generation,
@@ -12168,8 +12380,8 @@ export class AgentSession {
12168
12380
  pendingMessages?: AgentMessage[];
12169
12381
  }): ContextUsageBreakdown | undefined {
12170
12382
  const model = this.model;
12171
- const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
12172
- if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
12383
+ const rawContextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
12384
+ const contextWindow = Number.isFinite(rawContextWindow) && rawContextWindow > 0 ? rawContextWindow : 0;
12173
12385
 
12174
12386
  const { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens } = computeNonMessageBreakdown(this);
12175
12387
  const categoryNonMessageTokens = skillsTokens + toolsTokens + systemContextTokens + systemPromptTokens;
@@ -602,11 +602,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
602
602
  },
603
603
  {
604
604
  name: "share",
605
- description: "Share session via an encrypted link (secret gist or share server)",
605
+ description: "Share session via an encrypted link (share server or secret gist)",
606
606
  handle: async (_command, runtime) => {
607
607
  try {
608
608
  const result = await shareSession(runtime.sessionManager, {
609
609
  serverUrl: runtime.settings.get("share.serverUrl"),
610
+ store: runtime.settings.get("share.store"),
610
611
  state: runtime.session.state,
611
612
  obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined,
612
613
  });