@oh-my-pi/pi-coding-agent 16.2.6 → 16.2.8

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 (78) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli.js +2781 -2660
  3. package/dist/types/cli/bench-cli.d.ts +3 -3
  4. package/dist/types/commands/bench.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +39 -24
  6. package/dist/types/config/settings-schema.d.ts +92 -36
  7. package/dist/types/edit/hashline/filesystem.d.ts +1 -0
  8. package/dist/types/mcp/config-writer.d.ts +48 -0
  9. package/dist/types/mcp/types.d.ts +3 -0
  10. package/dist/types/session/agent-session.d.ts +33 -13
  11. package/dist/types/session/messages.d.ts +1 -1
  12. package/dist/types/session/session-context.d.ts +2 -7
  13. package/dist/types/session/session-entries.d.ts +2 -2
  14. package/dist/types/session/session-manager.d.ts +2 -2
  15. package/dist/types/session/settings-stream-fn.d.ts +2 -2
  16. package/dist/types/system-prompt.test.d.ts +1 -0
  17. package/dist/types/task/executor.d.ts +6 -6
  18. package/dist/types/task/types.d.ts +6 -0
  19. package/dist/types/task/worktree.d.ts +32 -6
  20. package/dist/types/tiny/title-client.d.ts +5 -1
  21. package/dist/types/tools/index.d.ts +3 -3
  22. package/dist/types/tools/output-schema-validator.d.ts +10 -0
  23. package/dist/types/utils/git.d.ts +17 -0
  24. package/package.json +12 -12
  25. package/src/cli/bench-cli.ts +19 -12
  26. package/src/cli/tiny-models-cli.ts +18 -4
  27. package/src/commands/bench.ts +3 -3
  28. package/src/config/mcp-schema.json +10 -1
  29. package/src/config/service-tier.ts +85 -56
  30. package/src/config/settings-schema.ts +81 -37
  31. package/src/config/settings.ts +47 -0
  32. package/src/discovery/builtin-rules/go-add-cleanup.md +32 -0
  33. package/src/discovery/builtin-rules/go-bench-loop.md +36 -0
  34. package/src/discovery/builtin-rules/go-exp-promoted.md +39 -0
  35. package/src/discovery/builtin-rules/go-ioutil.md +36 -0
  36. package/src/discovery/builtin-rules/go-join-hostport.md +29 -0
  37. package/src/discovery/builtin-rules/go-new-expr.md +44 -0
  38. package/src/discovery/builtin-rules/go-rand-v2.md +40 -0
  39. package/src/discovery/builtin-rules/go-range-int.md +45 -0
  40. package/src/discovery/builtin-rules/index.ts +16 -0
  41. package/src/edit/hashline/filesystem.ts +12 -0
  42. package/src/eval/agent-bridge.ts +4 -2
  43. package/src/internal-urls/docs-index.generated.txt +1 -1
  44. package/src/main.ts +1 -1
  45. package/src/mcp/config-writer.ts +121 -0
  46. package/src/mcp/config.ts +10 -6
  47. package/src/mcp/types.ts +3 -0
  48. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  49. package/src/modes/components/extensions/state-manager.ts +24 -3
  50. package/src/modes/controllers/event-controller.ts +7 -0
  51. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  52. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  53. package/src/prompts/tools/bash.md +1 -4
  54. package/src/sdk.ts +12 -11
  55. package/src/session/agent-session.ts +294 -82
  56. package/src/session/messages.ts +1 -1
  57. package/src/session/session-context.ts +17 -6
  58. package/src/session/session-entries.ts +2 -2
  59. package/src/session/session-manager.ts +9 -2
  60. package/src/session/settings-stream-fn.ts +12 -2
  61. package/src/slash-commands/builtin-registry.ts +2 -10
  62. package/src/system-prompt.test.ts +158 -0
  63. package/src/system-prompt.ts +69 -26
  64. package/src/task/executor.ts +23 -16
  65. package/src/task/index.ts +7 -5
  66. package/src/task/isolation-runner.ts +15 -1
  67. package/src/task/types.ts +6 -0
  68. package/src/task/worktree.ts +219 -38
  69. package/src/tiny/title-client.ts +19 -13
  70. package/src/tools/grep.ts +19 -1
  71. package/src/tools/index.ts +3 -3
  72. package/src/tools/irc.ts +9 -3
  73. package/src/tools/output-schema-validator.ts +38 -0
  74. package/src/tools/read.ts +28 -28
  75. package/src/tools/yield.ts +52 -15
  76. package/src/utils/file-mentions.ts +10 -1
  77. package/src/utils/git.ts +38 -0
  78. package/src/web/search/providers/duckduckgo.ts +17 -3
@@ -92,6 +92,8 @@ import type {
92
92
  ResetCreditRedeemOutcome,
93
93
  ResetCreditTarget,
94
94
  ServiceTier,
95
+ ServiceTierByFamily,
96
+ ServiceTierFamily,
95
97
  SimpleStreamOptions,
96
98
  TextContent,
97
99
  ToolCall,
@@ -105,7 +107,9 @@ import {
105
107
  deriveClaudeDeviceId,
106
108
  Effort,
107
109
  parseRateLimitReason,
108
- resolveServiceTier,
110
+ realizesPriorityServiceTier,
111
+ resolveModelServiceTier,
112
+ serviceTierFamily,
109
113
  streamSimple,
110
114
  } from "@oh-my-pi/pi-ai";
111
115
  import * as AIError from "@oh-my-pi/pi-ai/error";
@@ -168,7 +172,7 @@ import {
168
172
  } from "../config/model-resolver";
169
173
  import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
170
174
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
171
- import { resolveServiceTierSetting } from "../config/service-tier";
175
+ import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
172
176
  import type { Settings, SkillsSettings } from "../config/settings";
173
177
  import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
174
178
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
@@ -506,6 +510,8 @@ export interface AgentSessionConfig {
506
510
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
507
511
  /** Initial session thinking selector. */
508
512
  thinkingLevel?: ConfiguredThinkingLevel;
513
+ /** Initial per-family service tiers (OpenAI / Anthropic / Google) for the live session. */
514
+ serviceTierByFamily?: ServiceTierByFamily;
509
515
  /** Prompt templates for expansion */
510
516
  promptTemplates?: PromptTemplate[];
511
517
  /** File-based slash commands for expansion */
@@ -1787,6 +1793,7 @@ export class AgentSession {
1787
1793
  // toggle scopes priority to Fireworks alone, without mutating the shared
1788
1794
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1789
1795
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1796
+ this.#serviceTierByFamily = config.serviceTierByFamily ?? {};
1790
1797
  this.#advisorTools = config.advisorTools;
1791
1798
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1792
1799
  this.#advisorSharedInstructions = config.advisorSharedInstructions;
@@ -2045,15 +2052,20 @@ export class AgentSession {
2045
2052
  const legacy = !this.#advisorConfigs?.length;
2046
2053
  const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2047
2054
 
2048
- // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
2049
- // advisor on standard processing; "inherit" tracks the session's live tier
2050
- // per request (like the main agent, including /fast toggles) via a resolver;
2051
- // a concrete value pins the advisor to that tier. One value for all advisors.
2052
- const advisorTierSetting = this.settings.get("serviceTierAdvisor");
2053
- const advisorServiceTier =
2054
- advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
2055
- const advisorServiceTierResolver =
2056
- advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
2055
+ // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2056
+ // on standard processing; "inherit" tracks the session's live per-family
2057
+ // tiers per request (like the main agent, including /fast toggles); a
2058
+ // concrete value is broadcast across families and applied to the advisor
2059
+ // model's family. One value for all advisors.
2060
+ const advisorTierSetting = this.settings.get("tier.advisor");
2061
+ const advisorTierMap =
2062
+ advisorTierSetting === "inherit"
2063
+ ? undefined
2064
+ : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2065
+ const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2066
+ advisorTierSetting === "inherit"
2067
+ ? this.#effectiveServiceTier(model)
2068
+ : resolveModelServiceTier(advisorTierMap, model);
2057
2069
 
2058
2070
  const usedSlugs = new Set<string>();
2059
2071
  for (const config of roster) {
@@ -2152,7 +2164,7 @@ export class AgentSession {
2152
2164
  transformProviderContext: this.#transformProviderContext,
2153
2165
  intentTracing: false,
2154
2166
  telemetry: advisorTelemetry,
2155
- serviceTier: advisorServiceTier,
2167
+ serviceTier: undefined,
2156
2168
  serviceTierResolver: advisorServiceTierResolver,
2157
2169
  });
2158
2170
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
@@ -3215,10 +3227,11 @@ export class AgentSession {
3215
3227
  if (event.message.role === "assistant") {
3216
3228
  this.#lastAssistantMessage = event.message;
3217
3229
  const assistantMsg = event.message as AssistantMessage;
3218
- const currentGrantsAnthropicPriority =
3219
- this.serviceTier === "priority" || this.serviceTier === "claude-only";
3220
- if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
3221
- this.setServiceTier(undefined);
3230
+ if (
3231
+ assistantMsg.disabledFeatures?.includes("priority") &&
3232
+ this.#serviceTierByFamily.anthropic === "priority"
3233
+ ) {
3234
+ this.setServiceTierFamily("anthropic", undefined);
3222
3235
  this.emitNotice(
3223
3236
  "warning",
3224
3237
  "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
@@ -5203,8 +5216,11 @@ export class AgentSession {
5203
5216
  return this.#autoResolvedLevel;
5204
5217
  }
5205
5218
 
5206
- get serviceTier(): ServiceTier | undefined {
5207
- return this.agent.serviceTier;
5219
+ #serviceTierByFamily: ServiceTierByFamily = {};
5220
+
5221
+ /** Live per-family service tiers (OpenAI / Anthropic / Google). */
5222
+ get serviceTierByFamily(): ServiceTierByFamily {
5223
+ return this.#serviceTierByFamily;
5208
5224
  }
5209
5225
 
5210
5226
  /** Whether agent is currently streaming a response */
@@ -7892,7 +7908,7 @@ export class AgentSession {
7892
7908
  this.#scheduledHiddenNextTurnGeneration = undefined;
7893
7909
 
7894
7910
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
7895
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
7911
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
7896
7912
  if (nextDiscoverySessionToolNames) {
7897
7913
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
7898
7914
  if (this.getSelectedMCPToolNames().length > 0) {
@@ -8434,38 +8450,36 @@ export class AgentSession {
8434
8450
  }
8435
8451
 
8436
8452
  /**
8437
- * True when *any* fast-mode-granting service tier is configured, regardless
8438
- * of whether the active model's provider actually realizes it. Used by the
8439
- * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
8440
- * `claude-only`) doesn't silently broaden it to unscoped `priority`.
8453
+ * True when the currently selected model's family is set to `priority` — the
8454
+ * `/fast` on/off state for the active model. Returns false when no model is
8455
+ * selected or the model exposes no service-tier family (e.g. Fireworks, which
8456
+ * has its own Providers Fireworks Tier toggle).
8441
8457
  *
8442
- * For "is fast mode actually applied to the next request?" use
8443
- * {@link isFastModeActive} instead — that one respects the model's provider.
8458
+ * For "is priority actually applied to the next request?" use
8459
+ * {@link isFastModeActive} instead.
8444
8460
  */
8445
8461
  isFastModeEnabled(): boolean {
8446
- return (
8447
- this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
8448
- );
8462
+ const family = this.model ? serviceTierFamily(this.model) : undefined;
8463
+ return family ? this.#serviceTierByFamily[family] === "priority" : false;
8449
8464
  }
8450
8465
 
8451
8466
  /**
8452
- * True when the configured `serviceTier` resolves to `"priority"` for the
8453
- * *currently selected model's provider*. Returns false for scoped tiers
8454
- * that don't match (e.g. `"openai-only"` on an anthropic model) and when
8455
- * no model is selected.
8467
+ * True when `priority` is actually realized on the wire for the currently
8468
+ * selected model (OpenAI/Google `service_tier`, direct Anthropic fast mode,
8469
+ * or Fireworks priority). Returns false for tiers the active model can't
8470
+ * realize and when no model is selected.
8456
8471
  */
8457
8472
  isFastModeActive(): boolean {
8458
- return resolveServiceTier(this.#effectiveServiceTier(), this.model?.provider) === "priority";
8473
+ const model = this.model;
8474
+ return !!model && realizesPriorityServiceTier(this.#effectiveServiceTier(model), model);
8459
8475
  }
8460
8476
 
8461
8477
  /**
8462
- * Effective wire service-tier for a request to `model`. Fireworks models
8463
- * take the Priority serving path only when the Providers › Fireworks Tier
8464
- * setting is `"priority"` that toggle is the sole opt-in, so a global
8465
- * `serviceTier: "priority"` (for OpenAI/Anthropic) never silently incurs
8466
- * Fireworks priority costs and never for `-fast` variants, whose Fast
8467
- * serving path is mutually exclusive with Priority. Every other provider
8468
- * uses the session `serviceTier` unchanged.
8478
+ * Effective wire service-tier for a request to `model`. Fireworks models take
8479
+ * the Priority serving path only when the Providers › Fireworks Tier setting
8480
+ * is `"priority"` (and never for `-fast` variants, whose Fast serving path is
8481
+ * mutually exclusive with Priority). Every other model resolves the live
8482
+ * per-family tier map down to the entry for its family.
8469
8483
  */
8470
8484
  #effectiveServiceTier(model: Model | undefined = this.model): ServiceTier | undefined {
8471
8485
  if (model?.provider === "fireworks") {
@@ -8473,40 +8487,56 @@ export class AgentSession {
8473
8487
  ? "priority"
8474
8488
  : undefined;
8475
8489
  }
8476
- return this.serviceTier;
8490
+ if (!model) return undefined;
8491
+ return resolveModelServiceTier(this.#serviceTierByFamily, model);
8492
+ }
8493
+
8494
+ /** The live per-family tier map, or `null` when empty (for session persistence). */
8495
+ #serviceTierEntry(): ServiceTierByFamily | null {
8496
+ return Object.keys(this.#serviceTierByFamily).length > 0 ? this.#serviceTierByFamily : null;
8477
8497
  }
8478
8498
 
8479
- setServiceTier(serviceTier: ServiceTier | undefined): void {
8480
- if (this.serviceTier === serviceTier) return;
8481
- // Re-arming priority on Anthropic? Clear the per-session auto-fallback
8482
- // sticky disable so the next request actually carries `speed: "fast"`
8483
- // again. Without this, `/fast on` (or user switching to a tier that
8484
- // grants anthropic priority) after an auto-disable is a silent no-op
8485
- // and the warning notice fires every turn.
8486
- if (serviceTier === "priority" || serviceTier === "claude-only") {
8499
+ /** Set one family's tier (or clear it with `undefined`); persists the change. */
8500
+ setServiceTierFamily(family: ServiceTierFamily, tier: ServiceTier | undefined): void {
8501
+ if (this.#serviceTierByFamily[family] === tier) return;
8502
+ const next: ServiceTierByFamily = { ...this.#serviceTierByFamily };
8503
+ if (tier) next[family] = tier;
8504
+ else delete next[family];
8505
+ this.#applyServiceTierByFamily(next);
8506
+ }
8507
+
8508
+ /** Replace the whole per-family tier map; persists + re-arms Anthropic fast mode. */
8509
+ #applyServiceTierByFamily(next: ServiceTierByFamily): void {
8510
+ // Re-arming Anthropic priority clears the per-session fast-mode auto-disable
8511
+ // so the next request actually carries `speed: "fast"` again.
8512
+ if (next.anthropic === "priority" && this.#serviceTierByFamily.anthropic !== "priority") {
8487
8513
  clearAnthropicFastModeFallback(this.#providerSessionState);
8488
8514
  }
8489
- this.agent.serviceTier = serviceTier;
8490
- this.sessionManager.appendServiceTierChange(serviceTier ?? null);
8515
+ this.#serviceTierByFamily = next;
8516
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
8491
8517
  }
8492
8518
 
8519
+ /**
8520
+ * `/fast on|off` targets the family of the currently selected model: it sets
8521
+ * (or clears) that family's `priority` tier. Models without a service-tier
8522
+ * family (Fireworks, or providers with no tier knob) have nothing to toggle.
8523
+ */
8493
8524
  setFastMode(enabled: boolean): void {
8494
- if (enabled && this.isFastModeEnabled()) {
8495
- // Already on under any scope — keep the user's scoped value.
8525
+ const family = this.model ? serviceTierFamily(this.model) : undefined;
8526
+ if (!family) {
8527
+ this.emitNotice("info", "The current model has no service-tier control for /fast to toggle.", "priority");
8496
8528
  return;
8497
8529
  }
8498
8530
  if (!enabled) {
8499
- this.setServiceTier(undefined);
8531
+ if (this.#serviceTierByFamily[family] === "priority") this.setServiceTierFamily(family, undefined);
8500
8532
  return;
8501
8533
  }
8502
- const scope = this.settings.get("fastModeScope");
8503
- this.setServiceTier(scope === "openai" ? "openai-only" : scope === "claude" ? "claude-only" : "priority");
8534
+ this.setServiceTierFamily(family, "priority");
8504
8535
  }
8505
8536
 
8506
8537
  toggleFastMode(): boolean {
8507
- const enabled = !this.isFastModeEnabled();
8508
- this.setFastMode(enabled);
8509
- return enabled;
8538
+ this.setFastMode(!this.isFastModeEnabled());
8539
+ return this.isFastModeEnabled();
8510
8540
  }
8511
8541
 
8512
8542
  /**
@@ -8871,6 +8901,8 @@ export class AgentSession {
8871
8901
  const wantsSnapcompact =
8872
8902
  compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
8873
8903
  const snapcompactReady = wantsSnapcompact;
8904
+ const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
8905
+ let snapcompactShape: snapcompact.Shape | undefined;
8874
8906
  if (wantsSnapcompact && !this.model.input.includes("image")) {
8875
8907
  this.emitNotice(
8876
8908
  "warning",
@@ -8879,8 +8911,16 @@ export class AgentSession {
8879
8911
  );
8880
8912
  throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
8881
8913
  } else if (snapcompactReady) {
8882
- const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
8883
- const renderScan = snapcompact.scanRenderability(text);
8914
+ const text = snapcompact.serializeConversation(
8915
+ convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
8916
+ );
8917
+ const probeText = snapcompact.renderabilityProbeText(
8918
+ text,
8919
+ preparation.previousPreserveData,
8920
+ preparation.previousSummary,
8921
+ );
8922
+ snapcompactShape = snapcompact.resolveShapeForText(probeText, this.model, snapcompactShapeSetting);
8923
+ const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape });
8884
8924
  if (!renderScan.isSafe) {
8885
8925
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
8886
8926
  this.emitNotice(
@@ -8918,12 +8958,32 @@ export class AgentSession {
8918
8958
  );
8919
8959
  throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
8920
8960
  } else {
8961
+ const shape = snapcompactShape;
8962
+ if (!shape) {
8963
+ throw new Error("snapcompact shape was not resolved before rendering.");
8964
+ }
8921
8965
  snapcompactResult = await snapcompact.compact(preparation, {
8922
8966
  convertToLlm,
8923
8967
  model: this.model,
8924
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
8968
+ ...(snapcompactShapeSetting === "auto" ? {} : { shape }),
8925
8969
  maxFrames,
8926
8970
  });
8971
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
8972
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
8973
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
8974
+ model: this.model?.id,
8975
+ framePayloadBytes,
8976
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
8977
+ });
8978
+ this.emitNotice(
8979
+ "warning",
8980
+ "snapcompact produced too much standing image payload. No LLM fallback was attempted.",
8981
+ "compaction",
8982
+ );
8983
+ throw new Error(
8984
+ "snapcompact cannot run locally: standing image payload exceeds the per-request budget.",
8985
+ );
8986
+ }
8927
8987
  const ctxWindow = this.model?.contextWindow ?? 0;
8928
8988
  const budget =
8929
8989
  ctxWindow > 0
@@ -10886,7 +10946,7 @@ export class AgentSession {
10886
10946
  */
10887
10947
  #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
10888
10948
  const ctxWindow = this.model?.contextWindow ?? 0;
10889
- if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
10949
+ if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
10890
10950
  const reserve = effectiveReserveTokens(ctxWindow, settings);
10891
10951
  let baseTokens = computeNonMessageTokens(this);
10892
10952
  for (const message of preparation.recentMessages) {
@@ -10925,7 +10985,16 @@ export class AgentSession {
10925
10985
  const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
10926
10986
  const frameBudget = totalBudget - baseTokens - capReserve;
10927
10987
  if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
10928
- return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
10988
+ return Math.min(
10989
+ Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
10990
+ snapcompact.MAX_FRAMES_DEFAULT,
10991
+ snapcompact.maxFramesForDataBudget(),
10992
+ );
10993
+ }
10994
+
10995
+ #snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number {
10996
+ const archive = snapcompact.getPreservedArchive(result.preserveData);
10997
+ return archive ? snapcompact.frameDataBytes(archive.frames) : 0;
10929
10998
  }
10930
10999
 
10931
11000
  /**
@@ -10938,7 +11007,9 @@ export class AgentSession {
10938
11007
  */
10939
11008
  #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
10940
11009
  const archive = snapcompact.getPreservedArchive(result.preserveData);
10941
- const blocks = archive ? snapcompact.historyBlocks(archive) : undefined;
11010
+ const blocks = archive
11011
+ ? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET })
11012
+ : undefined;
10942
11013
  const summaryMessage = createCompactionSummaryMessage(
10943
11014
  result.summary,
10944
11015
  result.tokensBefore,
@@ -11029,6 +11100,52 @@ export class AgentSession {
11029
11100
  return residualTokens <= fitBudget;
11030
11101
  }
11031
11102
 
11103
+ /**
11104
+ * Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end.
11105
+ * The summarizer cut at the only available turn boundary, but the kept tail is
11106
+ * still over the recovery band because a single recent turn (a large
11107
+ * tool-result, a heavy fenced/XML block) is itself bigger than the band and
11108
+ * `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE
11109
+ * that tail — it offloads heavy tool-result / block content to one
11110
+ * `artifact://` blob and leaves a recoverable placeholder — so residual context
11111
+ * genuinely drops instead of the guard pausing maintenance and looping the
11112
+ * warning. Without it the guard would pause/warn here; with it the caller
11113
+ * re-tests its progress predicate after the elide pass and only falls through
11114
+ * to the warning when residual stays over.
11115
+ *
11116
+ * Image-only tails are out of scope: `collectShakeRegions` skips image-only
11117
+ * tool results and user-message images aren't counted by the local estimate
11118
+ * that gates the dead-end, so those still surface the warning (remedy:
11119
+ * `/shake images`).
11120
+ *
11121
+ * Returns the elide {@link ShakeResult} when something was offloaded (so the
11122
+ * caller can re-test and report), or `undefined` when nothing was eligible or
11123
+ * the pass aborted/failed.
11124
+ */
11125
+ async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise<ShakeResult | undefined> {
11126
+ if (signal.aborted) return undefined;
11127
+ try {
11128
+ const result = await this.shake("elide", { signal });
11129
+ return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined;
11130
+ } catch (error) {
11131
+ logger.warn("Dead-end shake rescue failed", {
11132
+ error: error instanceof Error ? error.message : String(error),
11133
+ });
11134
+ return undefined;
11135
+ }
11136
+ }
11137
+
11138
+ /** Notice describing a successful dead-end elide rescue. */
11139
+ #emitShakeRescueNotice(result: ShakeResult): void {
11140
+ const elided = result.toolResultsDropped + result.blocksDropped;
11141
+ const sink = result.artifactId ? "an artifact" : "placeholders";
11142
+ this.emitNotice(
11143
+ "info",
11144
+ `Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`,
11145
+ "compaction",
11146
+ );
11147
+ }
11148
+
11032
11149
  /**
11033
11150
  * Internal: Run auto-compaction with events.
11034
11151
  *
@@ -11062,6 +11179,7 @@ export class AgentSession {
11062
11179
  const shouldAutoContinue =
11063
11180
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
11064
11181
  const suppressHandoff = options.suppressHandoff === true;
11182
+ let fallbackFromShake = false;
11065
11183
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
11066
11184
  // reclaims nothing we fall through to the summary-compaction body below so
11067
11185
  // the oversized input still gets resolved.
@@ -11075,6 +11193,7 @@ export class AgentSession {
11075
11193
  suppressContinuation,
11076
11194
  );
11077
11195
  if (outcome !== "fallback") return outcome;
11196
+ fallbackFromShake = true;
11078
11197
  }
11079
11198
  // "overflow" and "incomplete" force inline execution because they are recovery
11080
11199
  // paths the caller wants resolved before scheduling the next turn. "idle" is
@@ -11273,7 +11392,14 @@ export class AgentSession {
11273
11392
  const text = snapcompact.serializeConversation(
11274
11393
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
11275
11394
  );
11276
- const renderScan = snapcompact.scanRenderability(text);
11395
+ const probeText = snapcompact.renderabilityProbeText(
11396
+ text,
11397
+ preparation.previousPreserveData,
11398
+ preparation.previousSummary,
11399
+ );
11400
+ const shapeSetting = this.settings.get("snapcompact.shape");
11401
+ const shape = snapcompact.resolveShapeForText(probeText, this.model, shapeSetting);
11402
+ const renderScan = snapcompact.scanRenderability(probeText, { shape });
11277
11403
  if (!renderScan.isSafe) {
11278
11404
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
11279
11405
  logger.warn("Snapcompact disabled: high non-ASCII rate detected", {
@@ -11293,9 +11419,20 @@ export class AgentSession {
11293
11419
  snapcompactResult = await snapcompact.compact(preparation, {
11294
11420
  convertToLlm,
11295
11421
  model: this.model,
11296
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11422
+ ...(shapeSetting === "auto" ? {} : { shape }),
11297
11423
  maxFrames,
11298
11424
  });
11425
+ const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
11426
+ if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
11427
+ logger.warn("Snapcompact exceeded the per-request frame payload budget", {
11428
+ model: this.model?.id,
11429
+ framePayloadBytes,
11430
+ budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
11431
+ });
11432
+ snapcompactBlocker =
11433
+ "snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
11434
+ snapcompactResult = undefined;
11435
+ }
11299
11436
  if (snapcompactResult) {
11300
11437
  const ctxWindow = this.model?.contextWindow ?? 0;
11301
11438
  const budget =
@@ -11555,7 +11692,15 @@ export class AgentSession {
11555
11692
  // won't include) is excluded. Reusing the auto-continue recovery band
11556
11693
  // here turned recoverable overflows into manual dead-ends (#3412 review),
11557
11694
  // so use the looser fit budget.
11558
- if (this.#compactionCreatedRetryFit()) {
11695
+ let retryFits = this.#compactionCreatedRetryFit();
11696
+ if (!retryFits && !fallbackFromShake) {
11697
+ const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
11698
+ if (rescue && this.#compactionCreatedRetryFit()) {
11699
+ retryFits = true;
11700
+ this.#emitShakeRescueNotice(rescue);
11701
+ }
11702
+ }
11703
+ if (retryFits) {
11559
11704
  this.#scheduleAgentContinue({ delayMs: 100, generation });
11560
11705
  continuationScheduled = true;
11561
11706
  } else {
@@ -11569,7 +11714,15 @@ export class AgentSession {
11569
11714
  // when auto-continue is disabled, a no-headroom threshold pass must still
11570
11715
  // block later automatic continuations (todo reminders/session_stop hooks)
11571
11716
  // from re-entering the same oversized context.
11572
- if (this.#compactionCreatedHeadroom()) {
11717
+ let hasHeadroom = this.#compactionCreatedHeadroom();
11718
+ if (!hasHeadroom && !fallbackFromShake) {
11719
+ const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
11720
+ if (rescue && this.#compactionCreatedHeadroom()) {
11721
+ hasHeadroom = true;
11722
+ this.#emitShakeRescueNotice(rescue);
11723
+ }
11724
+ }
11725
+ if (hasHeadroom) {
11573
11726
  if (shouldAutoContinue) {
11574
11727
  this.#scheduleAutoContinuePrompt(generation);
11575
11728
  continuationScheduled = true;
@@ -11593,7 +11746,7 @@ export class AgentSession {
11593
11746
  if (noProgressDeadEnd) {
11594
11747
  this.emitNotice(
11595
11748
  "warning",
11596
- "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
11749
+ "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.",
11597
11750
  "compaction",
11598
11751
  );
11599
11752
  }
@@ -12839,6 +12992,50 @@ export class AgentSession {
12839
12992
  // IRC Delivery
12840
12993
  // =========================================================================
12841
12994
 
12995
+ /**
12996
+ * Surfaces (and consumes) IRC incoming asides that have reached this running
12997
+ * session but have not yet been folded into the next model step.
12998
+ *
12999
+ * The inbox tool injects the formatted body into the tool result, so the
13000
+ * model sees it once via the result. Leaving the record in
13001
+ * {@link #pendingIrcAsides} would let the aside provider deliver it a second
13002
+ * time at the next step boundary — including on `peek`, which is why peek
13003
+ * also drains here.
13004
+ */
13005
+ drainPendingIrcInboxMessages(agentId: string): IrcMessage[] {
13006
+ const messages: IrcMessage[] = [];
13007
+ const remaining: CustomMessage[] = [];
13008
+ for (const record of this.#pendingIrcAsides) {
13009
+ if (record.customType !== "irc:incoming") {
13010
+ remaining.push(record);
13011
+ continue;
13012
+ }
13013
+ const details = record.details;
13014
+ if (!details || typeof details !== "object") {
13015
+ remaining.push(record);
13016
+ continue;
13017
+ }
13018
+ const id = Reflect.get(details, "id");
13019
+ const from = Reflect.get(details, "from");
13020
+ const body = Reflect.get(details, "message");
13021
+ const replyTo = Reflect.get(details, "replyTo");
13022
+ if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
13023
+ remaining.push(record);
13024
+ continue;
13025
+ }
13026
+ messages.push({
13027
+ id,
13028
+ from,
13029
+ to: agentId,
13030
+ body,
13031
+ ts: record.timestamp,
13032
+ ...(typeof replyTo === "string" ? { replyTo } : {}),
13033
+ });
13034
+ }
13035
+ this.#pendingIrcAsides = remaining;
13036
+ return messages;
13037
+ }
13038
+
12842
13039
  /**
12843
13040
  * Deliver an IRC message into this session (recipient side; called by the
12844
13041
  * IrcBus). Emits the `irc_message` session event for UI cards and injects
@@ -13150,7 +13347,15 @@ export class AgentSession {
13150
13347
  // Flush pending writes before switching so restore snapshots reflect committed state.
13151
13348
  await this.sessionManager.flush();
13152
13349
  const previousSessionState = this.sessionManager.captureState();
13153
- const previousSessionContext = this.buildDisplaySessionContext();
13350
+ // Only same-session reloads compare against the prior context to detect
13351
+ // rollback edits (`#didSessionMessagesChange` below). Building it for a
13352
+ // different-session switch is a pure waste — and on huge pre-fix sessions
13353
+ // it materializes every persisted snapcompact frame plus the
13354
+ // `openaiRemoteCompaction.replacementHistory` payload into messages,
13355
+ // blowing the heap before the new session even loads (issue #3846). The
13356
+ // error-recovery path rebuilds the context on demand from the restored
13357
+ // state instead.
13358
+ const previousSessionContext = switchingToDifferentSession ? undefined : this.buildDisplaySessionContext();
13154
13359
  // switchSession replaces these arrays wholesale during load/rollback, so retaining
13155
13360
  // the existing message objects is sufficient and avoids structured-clone failures for
13156
13361
  // extension/custom metadata that is valid to persist but not cloneable.
@@ -13163,7 +13368,7 @@ export class AgentSession {
13163
13368
  const previousThinkingLevel = this.#thinkingLevel;
13164
13369
  const previousAutoThinking = this.#autoThinking;
13165
13370
  const previousAutoResolvedLevel = this.#autoResolvedLevel;
13166
- const previousServiceTier = this.agent.serviceTier;
13371
+ const previousServiceTierByFamily = this.#serviceTierByFamily;
13167
13372
  const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
13168
13373
  const previousTools = [...this.agent.state.tools];
13169
13374
  const previousBaseSystemPrompt = this.#baseSystemPrompt;
@@ -13189,7 +13394,7 @@ export class AgentSession {
13189
13394
 
13190
13395
  const sessionContext = this.buildDisplaySessionContext();
13191
13396
  const didReloadConversationChange =
13192
- !switchingToDifferentSession &&
13397
+ previousSessionContext !== undefined &&
13193
13398
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
13194
13399
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
13195
13400
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
@@ -13249,7 +13454,11 @@ export class AgentSession {
13249
13454
  .getBranch()
13250
13455
  .some(entry => entry.type === "service_tier_change");
13251
13456
  const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
13252
- const configuredServiceTier = this.settings.get("serviceTier");
13457
+ const configuredServiceTierByFamily = buildServiceTierByFamily(
13458
+ this.settings.get("tier.openai"),
13459
+ this.settings.get("tier.anthropic"),
13460
+ this.settings.get("tier.google"),
13461
+ );
13253
13462
  // Restore the thinking selector. Each change persists the configured
13254
13463
  // selector (`auto` or a concrete level), so prefer it: an `auto` session
13255
13464
  // resumes in auto mode (reclassifying the next turn) instead of freezing at
@@ -13278,11 +13487,9 @@ export class AgentSession {
13278
13487
  this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel);
13279
13488
  }
13280
13489
  this.#applyThinkingLevelToAgent(this.#thinkingLevel);
13281
- this.agent.serviceTier = hasServiceTierEntry
13282
- ? sessionContext.serviceTier
13283
- : configuredServiceTier === "none"
13284
- ? undefined
13285
- : configuredServiceTier;
13490
+ this.#serviceTierByFamily = hasServiceTierEntry
13491
+ ? (sessionContext.serviceTier ?? {})
13492
+ : configuredServiceTierByFamily;
13286
13493
 
13287
13494
  if (switchingToDifferentSession) {
13288
13495
  await this.#resetMemoryContextForNewTranscript();
@@ -13305,7 +13512,12 @@ export class AgentSession {
13305
13512
  this.#rekeyMnemopiMemoryForCurrentSessionId();
13306
13513
  let restoreMcpError: unknown;
13307
13514
  try {
13308
- await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
13515
+ // `previousSessionContext` was skipped on different-session switches to
13516
+ // avoid materializing the previous session's heavy compaction payload
13517
+ // in the success path; rebuild it here on demand from the restored
13518
+ // state so MCP selection restoration still has its inputs.
13519
+ const mcpRestoreContext = previousSessionContext ?? this.buildDisplaySessionContext();
13520
+ await this.#restoreMCPSelectionsForSessionContext(mcpRestoreContext, {
13309
13521
  fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
13310
13522
  });
13311
13523
  } catch (mcpError) {
@@ -13334,7 +13546,7 @@ export class AgentSession {
13334
13546
  this.#autoThinking = previousAutoThinking;
13335
13547
  this.#autoResolvedLevel = previousAutoResolvedLevel;
13336
13548
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
13337
- this.agent.serviceTier = previousServiceTier;
13549
+ this.#serviceTierByFamily = previousServiceTierByFamily;
13338
13550
  this.#syncTodoPhasesFromBranch();
13339
13551
  this.#resetAllAdvisorRuntimes();
13340
13552
  this.#reconnectToAgent();
@@ -14288,7 +14500,7 @@ export class AgentSession {
14288
14500
  const payload = {
14289
14501
  model: this.agent.state.model ?? null,
14290
14502
  thinkingLevel: this.#thinkingLevel ?? null,
14291
- serviceTier: this.agent.serviceTier ?? null,
14503
+ serviceTier: this.#serviceTierEntry(),
14292
14504
  systemPrompt: this.agent.state.systemPrompt,
14293
14505
  tools: this.agent.state.tools.map(tool => ({
14294
14506
  name: tool.name,
@@ -488,7 +488,7 @@ export interface FileMentionMessage {
488
488
  /** File size in bytes, if known. */
489
489
  byteSize?: number;
490
490
  /** Why the file contents were omitted from auto-read. */
491
- skippedReason?: "tooLarge";
491
+ skippedReason?: "tooLarge" | "binary";
492
492
  image?: ImageContent;
493
493
  }>;
494
494
  timestamp: number;