@oh-my-pi/pi-coding-agent 15.7.3 → 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 (119) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/dist/types/config/settings-schema.d.ts +12 -22
  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/custom-tools/types.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  11. package/dist/types/internal-urls/types.d.ts +14 -0
  12. package/dist/types/lsp/client.d.ts +3 -0
  13. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  14. package/dist/types/lsp/index.d.ts +2 -0
  15. package/dist/types/lsp/utils.d.ts +4 -0
  16. package/dist/types/mcp/manager.d.ts +14 -5
  17. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  18. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  19. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  22. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +2 -2
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/theme/theme.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -2
  27. package/dist/types/session/agent-session.d.ts +10 -7
  28. package/dist/types/session/shake-types.d.ts +3 -3
  29. package/dist/types/task/repair-args.d.ts +52 -0
  30. package/dist/types/tiny/models.d.ts +0 -14
  31. package/dist/types/tiny/title-client.d.ts +28 -2
  32. package/dist/types/tiny/title-protocol.d.ts +8 -9
  33. package/dist/types/tools/ask.d.ts +8 -6
  34. package/dist/types/tools/eval-backends.d.ts +12 -0
  35. package/dist/types/tools/eval-render.d.ts +52 -0
  36. package/dist/types/tools/eval.d.ts +2 -35
  37. package/dist/types/tools/find.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +4 -11
  39. package/dist/types/tools/path-utils.d.ts +7 -0
  40. package/dist/types/tui/output-block.d.ts +11 -10
  41. package/examples/extensions/README.md +1 -0
  42. package/examples/extensions/thinking-note.ts +13 -0
  43. package/package.json +9 -9
  44. package/scripts/build-binary.ts +0 -1
  45. package/src/cli.ts +59 -0
  46. package/src/config/model-registry.ts +33 -4
  47. package/src/config/settings-schema.ts +13 -24
  48. package/src/config/settings.ts +10 -0
  49. package/src/discovery/claude.ts +41 -22
  50. package/src/edit/index.ts +23 -3
  51. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  52. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  53. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  54. package/src/eval/agent-bridge.ts +44 -38
  55. package/src/eval/heartbeat.ts +74 -0
  56. package/src/eval/js/executor.ts +13 -9
  57. package/src/eval/llm-bridge.ts +20 -14
  58. package/src/eval/py/executor.ts +14 -18
  59. package/src/exec/bash-executor.ts +31 -5
  60. package/src/extensibility/custom-tools/types.ts +2 -2
  61. package/src/extensibility/extensions/loader.ts +16 -18
  62. package/src/extensibility/extensions/runner.ts +22 -17
  63. package/src/extensibility/extensions/types.ts +39 -5
  64. package/src/extensibility/shared-events.ts +2 -2
  65. package/src/internal-urls/docs-index.generated.ts +5 -5
  66. package/src/internal-urls/local-protocol.ts +23 -11
  67. package/src/internal-urls/types.ts +15 -0
  68. package/src/lsp/client.ts +28 -5
  69. package/src/lsp/diagnostics-ledger.ts +51 -0
  70. package/src/lsp/index.ts +9 -22
  71. package/src/lsp/utils.ts +21 -0
  72. package/src/mcp/manager.ts +87 -4
  73. package/src/modes/acp/acp-agent.ts +8 -4
  74. package/src/modes/components/assistant-message.ts +28 -1
  75. package/src/modes/components/custom-editor.ts +9 -7
  76. package/src/modes/components/hook-selector.ts +159 -32
  77. package/src/modes/components/tool-execution.ts +20 -4
  78. package/src/modes/controllers/command-controller.ts +7 -39
  79. package/src/modes/controllers/event-controller.ts +38 -28
  80. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  81. package/src/modes/controllers/input-controller.ts +0 -15
  82. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  83. package/src/modes/interactive-mode.ts +2 -1
  84. package/src/modes/rpc/rpc-mode.ts +17 -6
  85. package/src/modes/theme/theme-schema.json +30 -0
  86. package/src/modes/theme/theme.ts +39 -2
  87. package/src/modes/types.ts +2 -1
  88. package/src/modes/utils/ui-helpers.ts +5 -2
  89. package/src/prompts/system/project-prompt.md +3 -2
  90. package/src/prompts/system/subagent-system-prompt.md +12 -8
  91. package/src/prompts/system/system-prompt.md +8 -6
  92. package/src/prompts/tools/ask.md +2 -1
  93. package/src/prompts/tools/eval.md +1 -1
  94. package/src/session/agent-session.ts +75 -103
  95. package/src/session/shake-types.ts +4 -5
  96. package/src/slash-commands/builtin-registry.ts +2 -4
  97. package/src/task/executor.ts +14 -4
  98. package/src/task/index.ts +3 -2
  99. package/src/task/repair-args.ts +117 -0
  100. package/src/tiny/models.ts +0 -28
  101. package/src/tiny/title-client.ts +133 -43
  102. package/src/tiny/title-protocol.ts +11 -16
  103. package/src/tiny/worker.ts +6 -61
  104. package/src/tools/ask.ts +74 -32
  105. package/src/tools/ast-edit.ts +3 -0
  106. package/src/tools/ast-grep.ts +3 -0
  107. package/src/tools/eval-backends.ts +38 -0
  108. package/src/tools/eval-render.ts +750 -0
  109. package/src/tools/eval.ts +27 -754
  110. package/src/tools/find.ts +20 -6
  111. package/src/tools/gh.ts +1 -0
  112. package/src/tools/index.ts +7 -37
  113. package/src/tools/path-utils.ts +13 -2
  114. package/src/tools/read.ts +1 -0
  115. package/src/tools/renderers.ts +1 -1
  116. package/src/tools/search.ts +12 -1
  117. package/src/tools/write.ts +9 -1
  118. package/src/tui/output-block.ts +42 -79
  119. package/src/utils/git.ts +9 -3
@@ -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;
@@ -1,4 +1,6 @@
1
- [PROJECT]
1
+ PROJECT
2
+ ===================================
3
+
2
4
  <workstation>
3
5
  {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
4
6
  </workstation>
@@ -47,4 +49,3 @@ Today is {{date}}, and the current working directory is '{{cwd}}'.
47
49
  {{#if appendPrompt}}
48
50
  {{appendPrompt}}
49
51
  {{/if}}
50
- [/PROJECT]
@@ -1,14 +1,18 @@
1
- [ROLE]
1
+ ROLE
2
+ ===================================
3
+
2
4
  {{agent}}
3
- [/ROLE]
4
5
 
5
6
  {{#if context}}
6
- [CONTEXT]
7
+ CONTEXT
8
+ ===================================
9
+
7
10
  {{context}}
8
- [/CONTEXT]
9
11
  {{/if}}
10
12
 
11
- [COOP]
13
+ COOP
14
+ ===================================
15
+
12
16
  You are operating on a piece of work assigned to you by the main agent.
13
17
 
14
18
  {{#if worktree}}
@@ -29,9 +33,10 @@ You can reach other live agents via the `irc` tool. Your id is `{{ircSelfId}}`.
29
33
 
30
34
  Use `irc` only when you need a quick answer from a peer; do not use it for long-form content. Address peers by id or use `"all"` to broadcast.
31
35
  {{/if}}
32
- [/COOP]
33
36
 
34
- [COMPLETION]
37
+ COMPLETION
38
+ ===================================
39
+
35
40
  No TODO tracking, no progress updates. Execute, call `yield`, done.
36
41
 
37
42
  While work remains, always continue with another tool call — investigate, edit, run, verify. Save narrative for the final `yield` payload.
@@ -51,4 +56,3 @@ Giving up is a last resort. If truly blocked, you MUST call `yield` exactly once
51
56
  You NEVER give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself.
52
57
 
53
58
  You MUST keep going until this ticket is closed. This matters.
54
- [/COMPLETION]
@@ -9,8 +9,8 @@ You consider what the code you write compiles down to. You never write code that
9
9
 
10
10
  <system-conventions>
11
11
  **RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.**
12
- From here on, we will use tags as structural markers (<x>…</x> or [X]…), each tag means exactly what its name says.
13
- You NEVER interpret these tags in any other way circumstantially.
12
+ From here on, we will use XML tags when injecting system content into the chat.
13
+ You NEVER interpret these markers in any other way circumstantially.
14
14
 
15
15
  System may interrupt/notify you using these tags even within a user message, therefore:
16
16
  - You MUST treat them as system-authored and absolutely authoritative.
@@ -44,7 +44,9 @@ Assumptions you didn't validate: incidents to debug.
44
44
  - You NEVER re-audit an applied edit, nor run `git status`/`git diff` as routine validation — the edit result, tests, and LSP ARE your verification. Exception: explicit request, protecting unrelated changes, or before commit/revert/reset/stash/delete.
45
45
  </critical>
46
46
 
47
- [ENV]
47
+ ENV
48
+ ===================================
49
+
48
50
  You operate within the Oh My Pi coding harness.
49
51
  - Given a task, you MUST complete it using the tools available to you.
50
52
  - You are not alone in this repository. You SHOULD treat unexpected changes as the user's work and adapt; you NEVER revert or stash.
@@ -202,9 +204,10 @@ You MUST use the specialized tool over its shell equivalent:
202
204
  The `{{toolRefs.report_tool_issue}}` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call `{{toolRefs.report_tool_issue}}` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.
203
205
  </critical>
204
206
  {{/has}}
205
- [/ENV]
206
207
 
207
- [CONTRACT]
208
+ CONTRACT
209
+ ===================================
210
+
208
211
  These are inviolable.
209
212
  - You NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or completed sub-step is NEVER a yield point — continue directly to the next step in the same turn.
210
213
  - You NEVER suppress tests to make code pass.
@@ -265,4 +268,3 @@ Before declaring blocked:
265
268
  - Do not test defaults: changing the default configuration, or a string, should not break the test. Assert logical behavior, not the current state.
266
269
  - Aim at: conditional branches and edge values, invariants across fields, error handling on bad input vs silent broken results.
267
270
  </workflow>
268
- [/CONTRACT]
@@ -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:**
@@ -51,11 +51,8 @@ import {
51
51
  prepareCompaction,
52
52
  type ShakeConfig,
53
53
  type ShakeRegion,
54
- type ShakeSummaryComplete,
55
- type ShakeSummaryItem,
56
54
  type SummaryOptions,
57
55
  shouldCompact,
58
- summarizeShakeRegions,
59
56
  } from "@oh-my-pi/pi-agent-core/compaction";
60
57
  import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
61
58
  import type {
@@ -162,6 +159,7 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
162
159
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
163
160
  import { parseTurnBudget } from "../modes/turn-budget";
164
161
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
162
+ import { computeNonMessageTokens } from "../modes/utils/context-usage";
165
163
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
166
164
  import type { PlanModeState } from "../plan-mode/state";
167
165
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -185,8 +183,7 @@ import {
185
183
  resolveThinkingLevelForModel,
186
184
  toReasoningEffort,
187
185
  } from "../thinking";
188
- import { isTinyMemoryLocalModelKey } from "../tiny/models";
189
- import { shutdownTinyTitleClient, tinyModelClient } from "../tiny/title-client";
186
+ import { shutdownTinyTitleClient } from "../tiny/title-client";
190
187
  import {
191
188
  buildDiscoverableToolSearchIndex,
192
189
  collectDiscoverableTools,
@@ -241,11 +238,11 @@ export type AgentSessionEvent =
241
238
  | {
242
239
  type: "auto_compaction_start";
243
240
  reason: "threshold" | "overflow" | "idle" | "incomplete";
244
- action: "context-full" | "handoff" | "shake" | "shake-summary";
241
+ action: "context-full" | "handoff" | "shake";
245
242
  }
246
243
  | {
247
244
  type: "auto_compaction_end";
248
- action: "context-full" | "handoff" | "shake" | "shake-summary";
245
+ action: "context-full" | "handoff" | "shake";
249
246
  result: CompactionResult | undefined;
250
247
  aborted: boolean;
251
248
  willRetry: boolean;
@@ -3621,8 +3618,13 @@ export class AgentSession {
3621
3618
  /**
3622
3619
  * Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
3623
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.
3624
3626
  */
3625
- async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
3627
+ async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise<void> {
3626
3628
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
3627
3629
  const existingNames = Array.from(this.#toolRegistry.keys());
3628
3630
  for (const name of existingNames) {
@@ -3663,6 +3665,18 @@ export class AgentSession {
3663
3665
  this.#getConfiguredDefaultSelectedMCPToolNames(),
3664
3666
  );
3665
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
+
3666
3680
  const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
3667
3681
  await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
3668
3682
  }
@@ -4283,7 +4297,7 @@ export class AgentSession {
4283
4297
  // next microtask alongside the new turn.
4284
4298
  const lastAssistant = this.#findLastAssistantMessage();
4285
4299
  if (lastAssistant && !options?.skipCompactionCheck) {
4286
- await this.#checkCompaction(lastAssistant, false, false);
4300
+ await this.#checkCompaction(lastAssistant, false, false, false);
4287
4301
  }
4288
4302
 
4289
4303
  // Build messages array (session context, eager todo prelude, then active prompt message)
@@ -4385,6 +4399,11 @@ export class AgentSession {
4385
4399
  }
4386
4400
  }
4387
4401
 
4402
+ await this.#runPrePromptCompactionIfNeeded(messages);
4403
+ if (this.#promptGeneration !== generation) {
4404
+ return;
4405
+ }
4406
+
4388
4407
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4389
4408
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4390
4409
  if (!options?.skipPostPromptRecoveryWait) {
@@ -5659,10 +5678,6 @@ export class AgentSession {
5659
5678
  * - `images` delegates to {@link dropImages}.
5660
5679
  * - `elide` replaces whole tool-call results and large fenced/XML blocks
5661
5680
  * with short placeholders that embed an `artifact://` recovery link.
5662
- * - `summary` extractively compresses the same regions with the configured
5663
- * local on-device model (`providers.shakeSummaryModel`), falling back to
5664
- * the elide placeholder per region (or wholesale when the local model is
5665
- * unavailable). Never calls a remote/cloud LLM.
5666
5681
  *
5667
5682
  * Mutates the branch in place, persists via `rewriteEntries`, replays the
5668
5683
  * rebuilt context through the agent, and tears down provider sessions that
@@ -5683,29 +5698,7 @@ export class AgentSession {
5683
5698
  }
5684
5699
 
5685
5700
  const artifactId = await this.#saveShakeArtifact(regions);
5686
- let replacements: string[];
5687
- if (mode === "summary") {
5688
- // Manual `/shake summary` installs the compaction controller so Esc /
5689
- // `abortCompaction()` can cancel the local-model pass; the auto-shake path
5690
- // passes its own signal and manages `#autoCompactionAbortController`.
5691
- let controller: AbortController | undefined;
5692
- let signal = opts.signal;
5693
- if (!signal) {
5694
- if (this.#compactionAbortController) throw new Error("Compaction already in progress");
5695
- controller = new AbortController();
5696
- this.#compactionAbortController = controller;
5697
- signal = controller.signal;
5698
- }
5699
- try {
5700
- replacements = await this.#buildShakeSummaryReplacements(regions, artifactId, signal);
5701
- } finally {
5702
- if (controller && this.#compactionAbortController === controller) {
5703
- this.#compactionAbortController = undefined;
5704
- }
5705
- }
5706
- } else {
5707
- replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
5708
- }
5701
+ const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
5709
5702
 
5710
5703
  let toolResultsDropped = 0;
5711
5704
  let blocksDropped = 0;
@@ -5762,56 +5755,6 @@ export class AgentSession {
5762
5755
  }
5763
5756
  }
5764
5757
 
5765
- /**
5766
- * Build per-region replacements for summary mode using the configured local
5767
- * on-device model (`providers.shakeSummaryModel`) via {@link tinyModelClient}.
5768
- * Shake summary never calls a remote/cloud LLM. When the configured model is
5769
- * not a known local key, every region falls back to the elide placeholder.
5770
- * Otherwise compresses via {@link summarizeShakeRegions}; per region, uses
5771
- * the parsed summary (with a recovery footer) or the elide placeholder when
5772
- * the local model omitted it / was unavailable. Any thrown failure degrades
5773
- * the whole batch to elide so the reduction still happens.
5774
- */
5775
- async #buildShakeSummaryReplacements(
5776
- regions: ShakeRegion[],
5777
- artifactId: string | undefined,
5778
- signal: AbortSignal | undefined,
5779
- ): Promise<string[]> {
5780
- const elide = (): string[] =>
5781
- regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
5782
-
5783
- const modelKey = this.settings.get("providers.shakeSummaryModel");
5784
- if (!isTinyMemoryLocalModelKey(modelKey)) return elide();
5785
-
5786
- const items: ShakeSummaryItem[] = regions.map((region, index) => ({
5787
- index,
5788
- label: region.label,
5789
- text: region.originalText,
5790
- }));
5791
-
5792
- const complete: ShakeSummaryComplete = (promptText, opts) =>
5793
- tinyModelClient.complete(modelKey, promptText, { maxTokens: opts.maxTokens, signal: opts.signal });
5794
-
5795
- let summaries: Map<number, string>;
5796
- try {
5797
- summaries = await summarizeShakeRegions(items, complete, { signal });
5798
- } catch (error) {
5799
- logger.warn("Shake summary compression failed; falling back to elide", {
5800
- error: error instanceof Error ? error.message : String(error),
5801
- });
5802
- return elide();
5803
- }
5804
-
5805
- return regions.map((region, index) => {
5806
- const summary = summaries.get(index);
5807
- if (!summary) return this.#shakeElidePlaceholder(region, index, artifactId);
5808
- if (artifactId) {
5809
- return `${summary}\n\n[recover full: artifact://${artifactId} (region ${index + 1})]`;
5810
- }
5811
- return summary;
5812
- });
5813
- }
5814
-
5815
5758
  /**
5816
5759
  * Manually compact the session context.
5817
5760
  * Aborts current agent operation first.
@@ -6171,6 +6114,34 @@ export class AgentSession {
6171
6114
  }
6172
6115
  }
6173
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
+
6174
6145
  /**
6175
6146
  * Check if context maintenance or promotion is needed and run it.
6176
6147
  * Called after agent_end and before prompt submission.
@@ -6191,6 +6162,7 @@ export class AgentSession {
6191
6162
  * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
6192
6163
  * on the pre-prompt path (where the next agent turn is about to start) set it to false
6193
6164
  * to avoid racing the deferred handoff against the new turn.
6165
+ * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
6194
6166
  * @returns true when a deferred handoff was scheduled. Callers MUST then skip any
6195
6167
  * subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
6196
6168
  * handoff will replace session state and a concurrent `agent.continue()` would
@@ -6200,6 +6172,7 @@ export class AgentSession {
6200
6172
  assistantMessage: AssistantMessage,
6201
6173
  skipAbortedCheck = true,
6202
6174
  allowDefer = true,
6175
+ autoContinue = true,
6203
6176
  ): Promise<boolean> {
6204
6177
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
6205
6178
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
@@ -6237,7 +6210,7 @@ export class AgentSession {
6237
6210
  // No promotion target available fall through to compaction
6238
6211
  const compactionSettings = this.settings.getGroup("compaction");
6239
6212
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
6240
- await this.#runAutoCompaction("overflow", true, false, allowDefer);
6213
+ await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
6241
6214
  }
6242
6215
  return false;
6243
6216
  }
@@ -6269,7 +6242,7 @@ export class AgentSession {
6269
6242
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6270
6243
  strategy: incompleteCompactionSettings.strategy,
6271
6244
  });
6272
- await this.#runAutoCompaction("incomplete", true, false, allowDefer);
6245
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6273
6246
  } else {
6274
6247
  // Neither promotion nor compaction is available — surface the dead-end so
6275
6248
  // the user understands why the turn yielded with nothing.
@@ -6295,7 +6268,7 @@ export class AgentSession {
6295
6268
  // Try promotion first — if a larger model is available, switch instead of compacting
6296
6269
  const promoted = await this.#tryContextPromotion(assistantMessage);
6297
6270
  if (!promoted) {
6298
- return await this.#runAutoCompaction("threshold", false, false, allowDefer);
6271
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6299
6272
  }
6300
6273
  }
6301
6274
  return false;
@@ -7019,17 +6992,18 @@ export class AgentSession {
7019
6992
  willRetry: boolean,
7020
6993
  deferred = false,
7021
6994
  allowDefer = true,
6995
+ options: { autoContinue?: boolean } = {},
7022
6996
  ): Promise<boolean> {
7023
6997
  const compactionSettings = this.settings.getGroup("compaction");
7024
6998
  if (compactionSettings.strategy === "off") return false;
7025
6999
  if (reason !== "idle" && !compactionSettings.enabled) return false;
7026
7000
  const generation = this.#promptGeneration;
7027
-
7028
- // Shake strategies run inline (cheap, no remote LLM). On overflow recovery,
7029
- // if shake reclaims nothing we fall through to the summary-compaction body
7030
- // below so the oversized input still gets resolved.
7031
- if (compactionSettings.strategy === "shake" || compactionSettings.strategy === "shake-summary") {
7032
- const outcome = await this.#runAutoShake(reason, compactionSettings.strategy, willRetry, generation);
7001
+ const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
7002
+ // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
7003
+ // reclaims nothing we fall through to the summary-compaction body below so
7004
+ // the oversized input still gets resolved.
7005
+ if (compactionSettings.strategy === "shake") {
7006
+ const outcome = await this.#runAutoShake(reason, willRetry, generation, shouldAutoContinue);
7033
7007
  if (outcome !== "fallback") return false;
7034
7008
  }
7035
7009
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7098,7 +7072,7 @@ export class AgentSession {
7098
7072
  aborted: false,
7099
7073
  willRetry: false,
7100
7074
  });
7101
- if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
7075
+ if (!autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue) {
7102
7076
  this.#scheduleAutoContinuePrompt(generation);
7103
7077
  }
7104
7078
  return false;
@@ -7350,7 +7324,7 @@ export class AgentSession {
7350
7324
  };
7351
7325
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
7352
7326
 
7353
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7327
+ if (!willRetry && reason !== "idle" && shouldAutoContinue) {
7354
7328
  this.#scheduleAutoContinuePrompt(generation);
7355
7329
  }
7356
7330
 
@@ -7426,20 +7400,18 @@ export class AgentSession {
7426
7400
  */
7427
7401
  async #runAutoShake(
7428
7402
  reason: "overflow" | "threshold" | "idle" | "incomplete",
7429
- strategy: "shake" | "shake-summary",
7430
7403
  willRetry: boolean,
7431
7404
  generation: number,
7405
+ autoContinue: boolean,
7432
7406
  ): Promise<"handled" | "fallback"> {
7433
- const action = strategy === "shake-summary" ? "shake-summary" : "shake";
7434
- const mode = strategy === "shake-summary" ? "summary" : "elide";
7407
+ const action = "shake";
7435
7408
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7436
7409
  this.#autoCompactionAbortController?.abort();
7437
7410
  const controller = new AbortController();
7438
7411
  this.#autoCompactionAbortController = controller;
7439
7412
  const signal = controller.signal;
7440
- const compactionSettings = this.settings.getGroup("compaction");
7441
7413
  try {
7442
- const result = await this.shake(mode, { config: DEFAULT_SHAKE_CONFIG, signal });
7414
+ const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
7443
7415
  if (signal.aborted) {
7444
7416
  await this.#emitSessionEvent({
7445
7417
  type: "auto_compaction_end",
@@ -7473,7 +7445,7 @@ export class AgentSession {
7473
7445
  skipped: !reclaimed,
7474
7446
  });
7475
7447
 
7476
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7448
+ if (!willRetry && reason !== "idle" && autoContinue) {
7477
7449
  this.#scheduleAutoContinuePrompt(generation);
7478
7450
  }
7479
7451
  if (willRetry) {
@@ -6,14 +6,14 @@
6
6
  */
7
7
 
8
8
  /** Mode selector for `AgentSession.shake`. */
9
- export type ShakeMode = "elide" | "summary" | "images";
9
+ export type ShakeMode = "elide" | "images";
10
10
 
11
11
  /** Outcome of an `AgentSession.shake` run. */
12
12
  export interface ShakeResult {
13
13
  mode: ShakeMode;
14
- /** Whole tool-call results dropped/compressed. */
14
+ /** Whole tool-call results dropped. */
15
15
  toolResultsDropped: number;
16
- /** Large fenced/XML blocks dropped/compressed. */
16
+ /** Large fenced/XML blocks dropped. */
17
17
  blocksDropped: number;
18
18
  /** Image blocks removed (images mode only). */
19
19
  imagesDropped?: number;
@@ -39,6 +39,5 @@ export function formatShakeSummary(result: ShakeResult): string {
39
39
  parts.push(`${result.blocksDropped} block${result.blocksDropped === 1 ? "" : "s"}`);
40
40
  }
41
41
  if (parts.length === 0) return "Nothing to shake.";
42
- const verb = result.mode === "summary" ? "Compressed" : "Shook";
43
- return `${verb} ${parts.join(" + ")} (~${result.tokensFreed} tokens freed).`;
42
+ return `Shook ${parts.join(" + ")} (~${result.tokensFreed} tokens freed).`;
44
43
  }