@oh-my-pi/pi-coding-agent 15.7.5 → 15.7.6

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 (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +9 -0
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  7. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  8. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  9. package/dist/types/lsp/index.d.ts +2 -0
  10. package/dist/types/lsp/utils.d.ts +4 -0
  11. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  13. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  14. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -2
  17. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +2 -2
  20. package/dist/types/session/agent-session.d.ts +8 -1
  21. package/dist/types/tools/ask.d.ts +8 -6
  22. package/dist/types/tools/eval-backends.d.ts +12 -0
  23. package/dist/types/tools/eval-render.d.ts +52 -0
  24. package/dist/types/tools/eval.d.ts +2 -35
  25. package/dist/types/tools/index.d.ts +4 -11
  26. package/dist/types/tui/output-block.d.ts +4 -3
  27. package/examples/extensions/README.md +1 -0
  28. package/examples/extensions/thinking-note.ts +13 -0
  29. package/package.json +9 -9
  30. package/src/config/model-registry.ts +33 -4
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/discovery/claude.ts +41 -22
  33. package/src/edit/index.ts +23 -3
  34. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  35. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  36. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  37. package/src/eval/agent-bridge.ts +44 -38
  38. package/src/eval/heartbeat.ts +74 -0
  39. package/src/eval/js/executor.ts +13 -9
  40. package/src/eval/llm-bridge.ts +20 -14
  41. package/src/eval/py/executor.ts +14 -18
  42. package/src/exec/bash-executor.ts +31 -5
  43. package/src/extensibility/extensions/loader.ts +16 -18
  44. package/src/extensibility/extensions/runner.ts +22 -17
  45. package/src/extensibility/extensions/types.ts +39 -5
  46. package/src/internal-urls/docs-index.generated.ts +3 -3
  47. package/src/lsp/diagnostics-ledger.ts +51 -0
  48. package/src/lsp/index.ts +9 -22
  49. package/src/lsp/utils.ts +21 -0
  50. package/src/modes/acp/acp-agent.ts +8 -4
  51. package/src/modes/components/assistant-message.ts +28 -1
  52. package/src/modes/components/custom-editor.ts +9 -7
  53. package/src/modes/components/hook-selector.ts +159 -32
  54. package/src/modes/components/tool-execution.ts +20 -4
  55. package/src/modes/controllers/event-controller.ts +5 -2
  56. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  57. package/src/modes/controllers/input-controller.ts +0 -15
  58. package/src/modes/interactive-mode.ts +2 -1
  59. package/src/modes/rpc/rpc-mode.ts +17 -6
  60. package/src/modes/theme/theme-schema.json +30 -0
  61. package/src/modes/theme/theme.ts +39 -2
  62. package/src/modes/types.ts +2 -1
  63. package/src/modes/utils/ui-helpers.ts +5 -2
  64. package/src/prompts/tools/ask.md +2 -1
  65. package/src/prompts/tools/eval.md +1 -1
  66. package/src/session/agent-session.ts +65 -11
  67. package/src/tools/ask.ts +74 -32
  68. package/src/tools/eval-backends.ts +38 -0
  69. package/src/tools/eval-render.ts +750 -0
  70. package/src/tools/eval.ts +27 -754
  71. package/src/tools/index.ts +7 -37
  72. package/src/tools/renderers.ts +1 -1
  73. package/src/tools/write.ts +9 -1
  74. package/src/tui/output-block.ts +5 -4
@@ -10,6 +10,7 @@ import type {
10
10
  ExtensionError,
11
11
  ExtensionUIContext,
12
12
  ExtensionUIDialogOptions,
13
+ ExtensionUISelectItem,
13
14
  ExtensionUiComponent,
14
15
  ExtensionWidgetContent,
15
16
  ExtensionWidgetOptions,
@@ -483,7 +484,7 @@ export class ExtensionUiController {
483
484
 
484
485
  createBackgroundUiContext(): ExtensionUIContext {
485
486
  return {
486
- select: async (_title: string, _options: string[], _dialogOptions) => undefined,
487
+ select: async (_title: string, _options: ExtensionUISelectItem[], _dialogOptions) => undefined,
487
488
  confirm: async (_title: string, _message: string, _dialogOptions) => false,
488
489
  input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
489
490
  notify: () => {},
@@ -581,7 +582,7 @@ export class ExtensionUiController {
581
582
  */
582
583
  showHookSelector(
583
584
  title: string,
584
- options: string[],
585
+ options: ExtensionUISelectItem[],
585
586
  dialogOptions?: ExtensionUIDialogOptions,
586
587
  extra?: { slider?: HookSelectorSlider },
587
588
  ): Promise<string | undefined> {
@@ -86,21 +86,6 @@ export class InputController {
86
86
 
87
87
  setupKeyHandlers(): void {
88
88
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
89
- this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
90
- Boolean(
91
- this.ctx.loadingAnimation ||
92
- this.ctx.hasActiveBtw() ||
93
- this.ctx.hasActiveOmfg() ||
94
- this.ctx.session.isStreaming ||
95
- this.ctx.session.isCompacting ||
96
- this.ctx.session.isGeneratingHandoff ||
97
- this.ctx.session.isBashRunning ||
98
- this.ctx.session.isEvalRunning ||
99
- this.ctx.autoCompactionLoader ||
100
- this.ctx.retryLoader ||
101
- this.ctx.autoCompactionEscapeHandler ||
102
- this.ctx.retryEscapeHandler,
103
- );
104
89
  this.ctx.editor.onEscape = () => {
105
90
  if (this.ctx.loopModeEnabled) {
106
91
  this.ctx.pauseLoop();
@@ -40,6 +40,7 @@ import { isSettingsInitialized, Settings, settings } from "../config/settings";
40
40
  import type {
41
41
  ExtensionUIContext,
42
42
  ExtensionUIDialogOptions,
43
+ ExtensionUISelectItem,
43
44
  ExtensionWidgetContent,
44
45
  ExtensionWidgetOptions,
45
46
  } from "../extensibility/extensions";
@@ -2896,7 +2897,7 @@ export class InteractiveMode implements InteractiveModeContext {
2896
2897
 
2897
2898
  showHookSelector(
2898
2899
  title: string,
2899
- options: string[],
2900
+ options: ExtensionUISelectItem[],
2900
2901
  dialogOptions?: ExtensionUIDialogOptions,
2901
2902
  extra?: { slider?: HookSelectorSlider },
2902
2903
  ): Promise<string | undefined> {
@@ -12,10 +12,12 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
- import type {
16
- ExtensionUIContext,
17
- ExtensionUIDialogOptions,
18
- ExtensionWidgetOptions,
15
+ import {
16
+ type ExtensionUIContext,
17
+ type ExtensionUIDialogOptions,
18
+ type ExtensionUISelectItem,
19
+ type ExtensionWidgetOptions,
20
+ getExtensionUISelectOptionLabel,
19
21
  } from "../../extensibility/extensions";
20
22
  import { type Theme, theme } from "../../modes/theme/theme";
21
23
  import type { AgentSession } from "../../session/agent-session";
@@ -256,11 +258,20 @@ export async function runRpcMode(
256
258
  return promise;
257
259
  }
258
260
 
259
- select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
261
+ select(
262
+ title: string,
263
+ options: ExtensionUISelectItem[],
264
+ dialogOptions?: ExtensionUIDialogOptions,
265
+ ): Promise<string | undefined> {
260
266
  return this.#createDialogPromise(
261
267
  dialogOptions,
262
268
  undefined,
263
- { method: "select", title, options, timeout: dialogOptions?.timeout },
269
+ {
270
+ method: "select",
271
+ title,
272
+ options: options.map(getExtensionUISelectOptionLabel),
273
+ timeout: dialogOptions?.timeout,
274
+ },
264
275
  response => parseValueDialogResponse(response, dialogOptions),
265
276
  );
266
277
  }
@@ -404,6 +404,36 @@
404
404
  "additionalProperties": {
405
405
  "type": "string"
406
406
  }
407
+ },
408
+ "spinnerFrames": {
409
+ "description": "Override the spinner frames. Use a flat array to set both `status` and `activity`, or an object to override each independently. Frames are advanced ~12.5fps for status spinners and ~60fps for activity spinners.",
410
+ "oneOf": [
411
+ {
412
+ "type": "array",
413
+ "minItems": 1,
414
+ "items": { "type": "string", "minLength": 1 }
415
+ },
416
+ {
417
+ "type": "object",
418
+ "properties": {
419
+ "status": {
420
+ "type": "array",
421
+ "minItems": 1,
422
+ "items": { "type": "string", "minLength": 1 }
423
+ },
424
+ "activity": {
425
+ "type": "array",
426
+ "minItems": 1,
427
+ "items": { "type": "string", "minLength": 1 }
428
+ }
429
+ },
430
+ "additionalProperties": false,
431
+ "anyOf": [
432
+ { "required": ["status"] },
433
+ { "required": ["activity"] }
434
+ ]
435
+ }
436
+ ]
407
437
  }
408
438
  },
409
439
  "additionalProperties": false
@@ -812,6 +812,25 @@ const SPINNER_FRAMES: Record<SymbolPreset, Record<SpinnerType, string[]>> = {
812
812
  },
813
813
  };
814
814
 
815
+ /**
816
+ * Shape accepted by `themeJson.symbols.spinnerFrames`. A flat array applies to
817
+ * both spinner types; an object lets a theme override `status` and/or
818
+ * `activity` independently. Anything not specified falls back to the symbol
819
+ * preset's default frames.
820
+ */
821
+ type SpinnerFramesOverride = string[] | { status?: string[]; activity?: string[] };
822
+
823
+ function normalizeSpinnerFramesOverride(
824
+ value: SpinnerFramesOverride | undefined,
825
+ ): Partial<Record<SpinnerType, string[]>> {
826
+ if (value === undefined) return {};
827
+ if (Array.isArray(value)) return { status: value, activity: value };
828
+ const result: Partial<Record<SpinnerType, string[]>> = {};
829
+ if (value.status) result.status = value.status;
830
+ if (value.activity) result.activity = value.activity;
831
+ return result;
832
+ }
833
+
815
834
  // ============================================================================
816
835
  // Types & Schema
817
836
  // ============================================================================
@@ -898,6 +917,19 @@ const themeColorsSchema = z.object(
898
917
  },
899
918
  );
900
919
 
920
+ const spinnerFramesArraySchema = z.array(z.string().min(1)).min(1);
921
+ const spinnerFramesSchema = z.union([
922
+ spinnerFramesArraySchema,
923
+ z
924
+ .object({
925
+ status: spinnerFramesArraySchema.optional(),
926
+ activity: spinnerFramesArraySchema.optional(),
927
+ })
928
+ .refine(value => value.status !== undefined || value.activity !== undefined, {
929
+ message: "spinnerFrames object must define `status` and/or `activity`",
930
+ }),
931
+ ]);
932
+
901
933
  const symbolPresetSchema = z.enum(["unicode", "nerd", "ascii"]);
902
934
 
903
935
  const themeJsonSchema = z.object({
@@ -916,6 +948,7 @@ const themeJsonSchema = z.object({
916
948
  .object({
917
949
  preset: symbolPresetSchema.optional(),
918
950
  overrides: z.record(z.string(), z.string()).optional(),
951
+ spinnerFrames: spinnerFramesSchema.optional(),
919
952
  })
920
953
  .optional(),
921
954
  });
@@ -1243,6 +1276,7 @@ export class Theme {
1243
1276
  #fgColors: Record<ThemeColor, string>;
1244
1277
  #bgColors: Record<ThemeBg, string>;
1245
1278
  #symbols: SymbolMap;
1279
+ #spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
1246
1280
 
1247
1281
  constructor(
1248
1282
  fgColors: Record<ThemeColor, string | number>,
@@ -1250,6 +1284,7 @@ export class Theme {
1250
1284
  private readonly mode: ColorMode,
1251
1285
  private readonly symbolPreset: SymbolPreset,
1252
1286
  symbolOverrides: Partial<Record<SymbolKey, string>>,
1287
+ spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>> = {},
1253
1288
  ) {
1254
1289
  this.#fgColors = {} as Record<ThemeColor, string>;
1255
1290
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@@ -1269,6 +1304,7 @@ export class Theme {
1269
1304
  logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) });
1270
1305
  }
1271
1306
  }
1307
+ this.#spinnerFramesOverrides = spinnerFramesOverrides;
1272
1308
  }
1273
1309
 
1274
1310
  fg(color: ThemeColor, text: string): string {
@@ -1563,7 +1599,7 @@ export class Theme {
1563
1599
  * Get spinner frames by type.
1564
1600
  */
1565
1601
  getSpinnerFrames(type: SpinnerType = "status"): string[] {
1566
- return SPINNER_FRAMES[this.symbolPreset][type];
1602
+ return this.#spinnerFramesOverrides[type] ?? SPINNER_FRAMES[this.symbolPreset][type];
1567
1603
  }
1568
1604
 
1569
1605
  /**
@@ -1735,7 +1771,8 @@ function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Th
1735
1771
  // Extract symbol configuration - settings override takes precedence over theme
1736
1772
  const symbolPreset: SymbolPreset = symbolPresetOverride ?? themeJson.symbols?.preset ?? "unicode";
1737
1773
  const symbolOverrides = themeJson.symbols?.overrides ?? {};
1738
- return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides);
1774
+ const spinnerFramesOverrides = normalizeSpinnerFramesOverride(themeJson.symbols?.spinnerFrames);
1775
+ return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides, spinnerFramesOverrides);
1739
1776
  }
1740
1777
 
1741
1778
  async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise<Theme> {
@@ -7,6 +7,7 @@ import type { Settings } from "../config/settings";
7
7
  import type {
8
8
  ExtensionUIContext,
9
9
  ExtensionUIDialogOptions,
10
+ ExtensionUISelectItem,
10
11
  ExtensionWidgetContent,
11
12
  ExtensionWidgetOptions,
12
13
  } from "../extensibility/extensions";
@@ -297,7 +298,7 @@ export interface InteractiveModeContext {
297
298
  setHookStatus(key: string, text: string | undefined): void;
298
299
  showHookSelector(
299
300
  title: string,
300
- options: string[],
301
+ options: ExtensionUISelectItem[],
301
302
  dialogOptions?: ExtensionUIDialogOptions,
302
303
  ): Promise<string | undefined>;
303
304
  hideHookSelector(): void;
@@ -262,8 +262,11 @@ export class UiHelpers {
262
262
  break;
263
263
  }
264
264
  case "assistant": {
265
- const assistantComponent = new AssistantMessageComponent(message, this.ctx.hideThinkingBlock, () =>
266
- this.ctx.ui.requestRender(),
265
+ const assistantComponent = new AssistantMessageComponent(
266
+ message,
267
+ this.ctx.hideThinkingBlock,
268
+ () => this.ctx.ui.requestRender(),
269
+ this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
267
270
  );
268
271
  this.ctx.chatContainer.addChild(assistantComponent);
269
272
  break;
@@ -8,6 +8,7 @@ Asks user when you need clarification or input during task execution.
8
8
  - Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
9
9
  - Use `questions` for multiple related questions instead of asking one at a time
10
10
  - Set `multi: true` on question to allow multiple selections
11
+ - Use short option labels; put explanatory tradeoffs in `description` instead of merging them into the label
11
12
  </instruction>
12
13
 
13
14
  <caution>
@@ -22,7 +23,7 @@ Asks user when you need clarification or input during task execution.
22
23
 
23
24
  <examples>
24
25
  # Single question
25
- questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}], "recommended": 0}]
26
+ questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT", "description": "Bearer tokens for stateless API clients."}, {"label": "OAuth2", "description": "Delegated authorization with external identity providers."}, {"label": "Session cookies", "description": "Browser-first authentication backed by server-side sessions."}], "recommended": 0}]
26
27
 
27
28
  # Multiple questions
28
29
  questions: [{"id": "storage_type", "question": "Which storage backend?", "options": [{"label": "SQLite"}, {"label": "PostgreSQL"}]}, {"id": "auth_method", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "Session cookies"}]}]
@@ -8,7 +8,7 @@ Cell fields:
8
8
  - `language` — {{#if py}}`"py"` for the IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` for the persistent JavaScript VM{{/if}}.
9
9
  - `code` — cell body, verbatim. Newlines, quotes, and indentation are JSON-encoded; no fences, no headers.
10
10
  - `title` (optional) — short label shown in the transcript (e.g. `"imports"`, `"load config"`).
11
- - `timeout` (optional) — per-cell **inactivity** budget in seconds (1-600). Default 30. The cell is interrupted only after this long with no progress, and every status event (`agent()` updates, `log()`/`phase()`, tool activity) resets the clock — so a long `agent()`/`parallel()` fanout that keeps reporting progress is not killed. Raw `print`/stdout does not reset it; raise `timeout` for a cell that runs long without emitting status.
11
+ - `timeout` (optional) — per-cell wall-clock budget in seconds (1-600). Default 30. It bounds the cell's **own** work, but is paused while an `agent()`/`parallel()`/`llm()` call is in flight — so a long fanout or a slow completion runs to completion, while the cell itself is still bounded. Compute, `print`/stdout, `log()`/`phase()`, and ordinary tool calls all count against the budget; raise `timeout` for a cell that does heavy local work or long non-agent tool calls.
12
12
  - `reset` (optional) — wipe this cell's language kernel before running.{{#ifAll py js}} Reset is per-language: a `py` cell's reset does not touch the JavaScript VM and vice versa.{{/ifAll}}
13
13
 
14
14
  **Work incrementally:**
@@ -159,6 +159,7 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
159
159
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
160
160
  import { parseTurnBudget } from "../modes/turn-budget";
161
161
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
162
+ import { computeNonMessageTokens } from "../modes/utils/context-usage";
162
163
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
163
164
  import type { PlanModeState } from "../plan-mode/state";
164
165
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -3617,8 +3618,13 @@ export class AgentSession {
3617
3618
  /**
3618
3619
  * Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
3619
3620
  * This allows /mcp add/remove/reauth to take effect without restarting the session.
3621
+ *
3622
+ * @param mcpTools The new MCP tools to register.
3623
+ * @param options.activateAll When true, force-activates every newly registered MCP tool
3624
+ * regardless of prior selection state. Used when an ACP client provisions MCP servers
3625
+ * for a session where MCP discovery is disabled.
3620
3626
  */
3621
- async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
3627
+ async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise<void> {
3622
3628
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
3623
3629
  const existingNames = Array.from(this.#toolRegistry.keys());
3624
3630
  for (const name of existingNames) {
@@ -3659,6 +3665,18 @@ export class AgentSession {
3659
3665
  this.#getConfiguredDefaultSelectedMCPToolNames(),
3660
3666
  );
3661
3667
 
3668
+ if (options?.activateAll) {
3669
+ // Force-activate every newly registered MCP tool. This path is used
3670
+ // when an ACP client provisions MCP servers for a session where MCP
3671
+ // discovery is disabled — without it, getSelectedMCPToolNames()
3672
+ // returns only already-active tools (circular deadlock: tools can
3673
+ // only become active if they're already active).
3674
+ const newMcpNames = mcpTools.map(t => t.name);
3675
+ const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...newMcpNames])];
3676
+ await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
3677
+ return;
3678
+ }
3679
+
3662
3680
  const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
3663
3681
  await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
3664
3682
  }
@@ -4279,7 +4297,7 @@ export class AgentSession {
4279
4297
  // next microtask alongside the new turn.
4280
4298
  const lastAssistant = this.#findLastAssistantMessage();
4281
4299
  if (lastAssistant && !options?.skipCompactionCheck) {
4282
- await this.#checkCompaction(lastAssistant, false, false);
4300
+ await this.#checkCompaction(lastAssistant, false, false, false);
4283
4301
  }
4284
4302
 
4285
4303
  // Build messages array (session context, eager todo prelude, then active prompt message)
@@ -4381,6 +4399,11 @@ export class AgentSession {
4381
4399
  }
4382
4400
  }
4383
4401
 
4402
+ await this.#runPrePromptCompactionIfNeeded(messages);
4403
+ if (this.#promptGeneration !== generation) {
4404
+ return;
4405
+ }
4406
+
4384
4407
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4385
4408
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4386
4409
  if (!options?.skipPostPromptRecoveryWait) {
@@ -6091,6 +6114,34 @@ export class AgentSession {
6091
6114
  }
6092
6115
  }
6093
6116
 
6117
+ #estimatePendingPromptTokens(messages: AgentMessage[]): number {
6118
+ let tokens = computeNonMessageTokens(this);
6119
+ for (const message of this.messages) {
6120
+ tokens += estimateTokens(message);
6121
+ }
6122
+ for (const message of messages) {
6123
+ tokens += estimateTokens(message);
6124
+ }
6125
+ return tokens;
6126
+ }
6127
+
6128
+ async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
6129
+ const model = this.model;
6130
+ if (!model) return;
6131
+ const contextWindow = model.contextWindow ?? 0;
6132
+ if (contextWindow <= 0) return;
6133
+ const compactionSettings = this.settings.getGroup("compaction");
6134
+ const contextTokens = this.#estimatePendingPromptTokens(messages);
6135
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
6136
+
6137
+ logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
6138
+ contextTokens,
6139
+ contextWindow,
6140
+ model: `${model.provider}/${model.id}`,
6141
+ });
6142
+ await this.#runAutoCompaction("threshold", false, false, false, { autoContinue: false });
6143
+ }
6144
+
6094
6145
  /**
6095
6146
  * Check if context maintenance or promotion is needed and run it.
6096
6147
  * Called after agent_end and before prompt submission.
@@ -6111,6 +6162,7 @@ export class AgentSession {
6111
6162
  * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
6112
6163
  * on the pre-prompt path (where the next agent turn is about to start) set it to false
6113
6164
  * to avoid racing the deferred handoff against the new turn.
6165
+ * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
6114
6166
  * @returns true when a deferred handoff was scheduled. Callers MUST then skip any
6115
6167
  * subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
6116
6168
  * handoff will replace session state and a concurrent `agent.continue()` would
@@ -6120,6 +6172,7 @@ export class AgentSession {
6120
6172
  assistantMessage: AssistantMessage,
6121
6173
  skipAbortedCheck = true,
6122
6174
  allowDefer = true,
6175
+ autoContinue = true,
6123
6176
  ): Promise<boolean> {
6124
6177
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
6125
6178
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
@@ -6157,7 +6210,7 @@ export class AgentSession {
6157
6210
  // No promotion target available fall through to compaction
6158
6211
  const compactionSettings = this.settings.getGroup("compaction");
6159
6212
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
6160
- await this.#runAutoCompaction("overflow", true, false, allowDefer);
6213
+ await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
6161
6214
  }
6162
6215
  return false;
6163
6216
  }
@@ -6189,7 +6242,7 @@ export class AgentSession {
6189
6242
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6190
6243
  strategy: incompleteCompactionSettings.strategy,
6191
6244
  });
6192
- await this.#runAutoCompaction("incomplete", true, false, allowDefer);
6245
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6193
6246
  } else {
6194
6247
  // Neither promotion nor compaction is available — surface the dead-end so
6195
6248
  // the user understands why the turn yielded with nothing.
@@ -6215,7 +6268,7 @@ export class AgentSession {
6215
6268
  // Try promotion first — if a larger model is available, switch instead of compacting
6216
6269
  const promoted = await this.#tryContextPromotion(assistantMessage);
6217
6270
  if (!promoted) {
6218
- return await this.#runAutoCompaction("threshold", false, false, allowDefer);
6271
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6219
6272
  }
6220
6273
  }
6221
6274
  return false;
@@ -6939,17 +6992,18 @@ export class AgentSession {
6939
6992
  willRetry: boolean,
6940
6993
  deferred = false,
6941
6994
  allowDefer = true,
6995
+ options: { autoContinue?: boolean } = {},
6942
6996
  ): Promise<boolean> {
6943
6997
  const compactionSettings = this.settings.getGroup("compaction");
6944
6998
  if (compactionSettings.strategy === "off") return false;
6945
6999
  if (reason !== "idle" && !compactionSettings.enabled) return false;
6946
7000
  const generation = this.#promptGeneration;
6947
-
7001
+ const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
6948
7002
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
6949
7003
  // reclaims nothing we fall through to the summary-compaction body below so
6950
7004
  // the oversized input still gets resolved.
6951
7005
  if (compactionSettings.strategy === "shake") {
6952
- const outcome = await this.#runAutoShake(reason, willRetry, generation);
7006
+ const outcome = await this.#runAutoShake(reason, willRetry, generation, shouldAutoContinue);
6953
7007
  if (outcome !== "fallback") return false;
6954
7008
  }
6955
7009
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7018,7 +7072,7 @@ export class AgentSession {
7018
7072
  aborted: false,
7019
7073
  willRetry: false,
7020
7074
  });
7021
- if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
7075
+ if (!autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue) {
7022
7076
  this.#scheduleAutoContinuePrompt(generation);
7023
7077
  }
7024
7078
  return false;
@@ -7270,7 +7324,7 @@ export class AgentSession {
7270
7324
  };
7271
7325
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
7272
7326
 
7273
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7327
+ if (!willRetry && reason !== "idle" && shouldAutoContinue) {
7274
7328
  this.#scheduleAutoContinuePrompt(generation);
7275
7329
  }
7276
7330
 
@@ -7348,6 +7402,7 @@ export class AgentSession {
7348
7402
  reason: "overflow" | "threshold" | "idle" | "incomplete",
7349
7403
  willRetry: boolean,
7350
7404
  generation: number,
7405
+ autoContinue: boolean,
7351
7406
  ): Promise<"handled" | "fallback"> {
7352
7407
  const action = "shake";
7353
7408
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -7355,7 +7410,6 @@ export class AgentSession {
7355
7410
  const controller = new AbortController();
7356
7411
  this.#autoCompactionAbortController = controller;
7357
7412
  const signal = controller.signal;
7358
- const compactionSettings = this.settings.getGroup("compaction");
7359
7413
  try {
7360
7414
  const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
7361
7415
  if (signal.aborted) {
@@ -7391,7 +7445,7 @@ export class AgentSession {
7391
7445
  skipped: !reclaimed,
7392
7446
  });
7393
7447
 
7394
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7448
+ if (!willRetry && reason !== "idle" && autoContinue) {
7395
7449
  this.#scheduleAutoContinuePrompt(generation);
7396
7450
  }
7397
7451
  if (willRetry) {