@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.0

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 (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli.js +2872 -2908
  3. package/dist/types/config/settings-schema.d.ts +14 -4
  4. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  5. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  6. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  7. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  8. package/dist/types/modes/components/index.d.ts +0 -1
  9. package/dist/types/modes/components/message-frame.d.ts +6 -4
  10. package/dist/types/modes/interactive-mode.d.ts +2 -1
  11. package/dist/types/modes/theme/theme.d.ts +7 -1
  12. package/dist/types/modes/types.d.ts +7 -1
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +20 -1
  15. package/dist/types/session/session-context.d.ts +7 -0
  16. package/dist/types/session/session-dump-format.d.ts +1 -0
  17. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  18. package/dist/types/system-prompt.d.ts +3 -3
  19. package/dist/types/tools/index.d.ts +4 -0
  20. package/dist/types/tools/resolve.d.ts +15 -5
  21. package/package.json +12 -12
  22. package/src/config/settings-schema.ts +16 -4
  23. package/src/debug/log-viewer.ts +4 -4
  24. package/src/debug/raw-sse.ts +4 -4
  25. package/src/edit/renderer.ts +2 -2
  26. package/src/internal-urls/docs-index.generated.txt +1 -1
  27. package/src/lsp/render.ts +7 -7
  28. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  29. package/src/modes/components/agent-dashboard.ts +1 -1
  30. package/src/modes/components/assistant-message.ts +21 -0
  31. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  32. package/src/modes/components/chat-transcript-builder.ts +16 -2
  33. package/src/modes/components/compaction-summary-message.ts +29 -1
  34. package/src/modes/components/custom-message.ts +4 -1
  35. package/src/modes/components/dynamic-border.ts +1 -1
  36. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  37. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  38. package/src/modes/components/hook-selector.ts +2 -2
  39. package/src/modes/components/index.ts +0 -1
  40. package/src/modes/components/message-frame.ts +10 -6
  41. package/src/modes/components/model-selector.ts +2 -2
  42. package/src/modes/components/overlay-box.ts +10 -9
  43. package/src/modes/components/skill-message.ts +39 -19
  44. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  45. package/src/modes/components/welcome.ts +1 -1
  46. package/src/modes/controllers/event-controller.ts +14 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +9 -1
  49. package/src/modes/theme/theme.ts +14 -0
  50. package/src/modes/types.ts +7 -1
  51. package/src/modes/utils/ui-helpers.ts +20 -2
  52. package/src/prompts/steering/user-interjection.md +3 -4
  53. package/src/sdk.ts +8 -6
  54. package/src/session/agent-session.ts +90 -13
  55. package/src/session/messages.ts +7 -9
  56. package/src/session/session-context.ts +54 -7
  57. package/src/session/session-dump-format.ts +3 -1
  58. package/src/session/snapcompact-inline.ts +2 -2
  59. package/src/session/tool-choice-queue.ts +59 -0
  60. package/src/system-prompt.ts +10 -9
  61. package/src/tools/bash-interactive.ts +4 -4
  62. package/src/tools/index.ts +4 -0
  63. package/src/tools/resolve.ts +66 -41
  64. package/src/tui/output-block.ts +9 -9
  65. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  66. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -3,6 +3,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
4
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
5
  import type { CustomMessage, SkillPromptDetails } from "../../session/messages";
6
+ import { shortenPath } from "../../tools/render-utils";
7
+ import { fileHyperlink } from "../../tui";
6
8
 
7
9
  export class SkillMessageComponent extends Container {
8
10
  #box: Box;
@@ -38,25 +40,26 @@ export class SkillMessageComponent extends Container {
38
40
  this.removeChild(this.#box);
39
41
  this.addChild(this.#box);
40
42
  this.#box.clear();
41
-
42
- const label = theme.fg("customMessageLabel", theme.bold("[skill]"));
43
- this.#box.addChild(new Text(label, 0, 0));
44
- this.#box.addChild(new Spacer(1));
43
+ // Re-read symbols every rebuild so a runtime theme/preset switch refreshes the outline.
44
+ this.#box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) });
45
45
 
46
46
  const details = this.message.details;
47
- const args = details?.args?.trim();
48
- const infoLines = [
49
- `Skill: ${details?.name ?? "unknown"}`,
50
- args ? `Args: ${args}` : undefined,
51
- details?.path ? `Path: ${details.path}` : undefined,
52
- typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined,
53
- ].filter((line): line is string => Boolean(line));
54
-
55
- this.#box.addChild(
56
- new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), {
57
- color: (value: string) => theme.fg("customMessageText", value),
58
- }),
59
- );
47
+ const name = details?.name?.trim() || "unknown";
48
+ // Collapse args to one line: a stray newline/tab in user-supplied args would split the header.
49
+ const args = details?.args?.replace(/\s+/g, " ").trim() ?? "";
50
+
51
+ // Header: icon-tag + skill name, with the invocation args trailing dimmed.
52
+ const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`));
53
+ let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`;
54
+ if (args) {
55
+ header += ` ${theme.fg("dim", args)}`;
56
+ }
57
+ this.#box.addChild(new Text(header, 0, 0));
58
+
59
+ const meta = this.#metaLine(details);
60
+ if (meta) {
61
+ this.#box.addChild(new Text(meta, 0, 0));
62
+ }
60
63
 
61
64
  if (!this.#expanded) {
62
65
  return;
@@ -68,8 +71,7 @@ export class SkillMessageComponent extends Container {
68
71
  }
69
72
 
70
73
  this.#box.addChild(new Spacer(1));
71
- const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt"));
72
- this.#box.addChild(new Text(promptHeader, 0, 0));
74
+ this.#box.addChild(new Text(theme.fg("muted", "prompt"), 0, 0));
73
75
  this.#box.addChild(new Spacer(1));
74
76
 
75
77
  this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), {
@@ -78,6 +80,24 @@ export class SkillMessageComponent extends Container {
78
80
  this.#box.addChild(this.#contentComponent);
79
81
  }
80
82
 
83
+ /** Sub-line under the header: home-shortened (clickable) accent path · muted prompt size. */
84
+ #metaLine(details: SkillPromptDetails | undefined): string | undefined {
85
+ const parts: string[] = [];
86
+
87
+ const filePath = details?.path;
88
+ if (filePath) {
89
+ parts.push(fileHyperlink(filePath, theme.fg("accent", shortenPath(filePath)), { line: 1 }));
90
+ }
91
+ if (typeof details?.lineCount === "number") {
92
+ parts.push(theme.fg("muted", `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`));
93
+ }
94
+
95
+ if (parts.length === 0) {
96
+ return undefined;
97
+ }
98
+ return ` ${parts.join(theme.fg("muted", theme.sep.dot))}`;
99
+ }
100
+
81
101
  #extractText(): string {
82
102
  if (typeof this.message.content === "string") {
83
103
  return this.message.content;
@@ -74,7 +74,7 @@ export class TinyTitleDownloadProgressComponent implements Component {
74
74
  render(width: number): readonly string[] {
75
75
  width = Math.max(1, width);
76
76
  const spec = getTinyTitleModelSpec(this.#modelKey);
77
- const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width));
77
+ const border = theme.fg("border", theme.boxRound.horizontal.repeat(width));
78
78
  const status = statusLabel(this.#event);
79
79
  const file = currentFile(this.#event);
80
80
  const pct =
@@ -308,7 +308,7 @@ export class WelcomeComponent implements Component {
308
308
  }
309
309
  // Bottom border
310
310
  if (showRightColumn) {
311
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
311
+ lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
312
312
  } else {
313
313
  lines.push(bl + h.repeat(leftCol) + br);
314
314
  }
@@ -5,6 +5,7 @@ import { extractTextContent } from "../../commit/utils";
5
5
  import { settings } from "../../config/settings";
6
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
7
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
+ import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
8
9
  import {
9
10
  ReadToolGroupComponent,
10
11
  readArgsHaveTarget,
@@ -659,6 +660,16 @@ export class EventController {
659
660
  // waiting poll cannot be displaced anymore — freeze it in place.
660
661
  this.#resolveDisplaceablePoll();
661
662
  }
663
+ // Surface a prompt-cache invalidation: if the previous turn cached a
664
+ // meaningful prefix and this request read none of it back, flag the turn.
665
+ const usage = event.message.usage;
666
+ if (usage.cacheRead + usage.cacheWrite + usage.input > 0) {
667
+ if (settings.get("display.cacheMissMarker")) {
668
+ const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage);
669
+ if (invalidation) this.ctx.streamingComponent.setCacheInvalidation(invalidation);
670
+ }
671
+ this.ctx.lastAssistantUsage = usage;
672
+ }
662
673
  this.#lastAssistantComponent = this.ctx.streamingComponent;
663
674
  this.#lastAssistantComponent.markTranscriptBlockFinalized();
664
675
  if (settings.get("display.showTokenUsage")) {
@@ -969,12 +980,14 @@ export class EventController {
969
980
  }
970
981
  this.ctx.showWarning(event.errorMessage);
971
982
  } else if (!event.skipped) {
983
+ this.ctx.lastAssistantUsage = undefined;
972
984
  this.ctx.rebuildChatFromMessages();
973
985
  this.ctx.statusLine.invalidate();
974
986
  this.ctx.updateEditorTopBorder();
975
987
  this.ctx.showStatus("Auto-shake completed");
976
988
  }
977
989
  } else if (event.result) {
990
+ this.ctx.lastAssistantUsage = undefined;
978
991
  this.ctx.rebuildChatFromMessages();
979
992
  this.ctx.statusLine.invalidate();
980
993
  this.ctx.updateEditorTopBorder();
@@ -982,6 +995,7 @@ export class EventController {
982
995
  this.ctx.showWarning(event.errorMessage);
983
996
  } else if (isHandoffAction) {
984
997
  this.ctx.chatContainer.clear();
998
+ this.ctx.lastAssistantUsage = undefined;
985
999
  this.ctx.rebuildChatFromMessages();
986
1000
  this.ctx.statusLine.invalidate();
987
1001
  this.ctx.updateEditorTopBorder();
@@ -327,6 +327,13 @@ export class SelectorController {
327
327
  // InputController.toggleThinkingBlockVisibility).
328
328
  this.ctx.ui.resetDisplay();
329
329
  break;
330
+ case "display.cacheMissMarker":
331
+ // Rebuild re-runs the usage-based detection under the new setting so
332
+ // markers appear/disappear; full reset retires any already committed
333
+ // to native scrollback (mirrors hideThinking).
334
+ this.ctx.rebuildChatFromMessages();
335
+ this.ctx.ui.resetDisplay();
336
+ break;
330
337
  case "tui.tight":
331
338
  setTuiTight(value as boolean);
332
339
  this.ctx.ui.invalidate();
@@ -12,7 +12,7 @@ import {
12
12
  ThinkingLevel,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
15
- import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
15
+ import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai";
16
16
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
17
17
  import type {
18
18
  Component,
@@ -412,6 +412,7 @@ export class InteractiveMode implements InteractiveModeContext {
412
412
  isPythonMode = false;
413
413
  streamingComponent: AssistantMessageComponent | undefined = undefined;
414
414
  streamingMessage: AssistantMessage | undefined = undefined;
415
+ lastAssistantUsage: Usage | undefined = undefined;
415
416
  loadingAnimation: Loader | undefined = undefined;
416
417
  autoCompactionLoader: Loader | undefined = undefined;
417
418
  retryLoader: Loader | undefined = undefined;
@@ -512,6 +513,7 @@ export class InteractiveMode implements InteractiveModeContext {
512
513
  this.compactionQueuedMessages = [];
513
514
  this.streamingComponent = undefined;
514
515
  this.streamingMessage = undefined;
516
+ this.lastAssistantUsage = undefined;
515
517
  this.pendingTools.clear();
516
518
  }
517
519
  readonly #uiHelpers: UiHelpers;
@@ -1858,6 +1860,9 @@ export class InteractiveMode implements InteractiveModeContext {
1858
1860
  this.#planModePreviousTools = previousTools;
1859
1861
  this.planModePlanFilePath = planFilePath;
1860
1862
  this.planModeEnabled = true;
1863
+ // Suppress cache-miss marker on the next turn: plan mode changes the system
1864
+ // prompt, which predictably invalidates the cache.
1865
+ this.lastAssistantUsage = undefined;
1861
1866
 
1862
1867
  await this.session.setActiveToolsByName(uniquePlanTools);
1863
1868
  this.session.setPlanModeState({
@@ -1975,6 +1980,9 @@ export class InteractiveMode implements InteractiveModeContext {
1975
1980
  this.session.setStandingResolveHandler?.(null);
1976
1981
  this.session.setPlanModeState(undefined);
1977
1982
  this.planModeEnabled = false;
1983
+ // Suppress cache-miss marker on the next turn: plan exit changes the system
1984
+ // prompt, which predictably invalidates the cache.
1985
+ this.lastAssistantUsage = undefined;
1978
1986
  this.planModePaused = options?.paused ?? false;
1979
1987
  this.planModePlanFilePath = undefined;
1980
1988
  this.#planModePreviousTools = undefined;
@@ -111,6 +111,7 @@ export type SymbolKey =
111
111
  | "icon.agents"
112
112
  | "icon.job"
113
113
  | "icon.cache"
114
+ | "icon.cacheMiss"
114
115
  | "icon.input"
115
116
  | "icon.output"
116
117
  | "icon.host"
@@ -310,6 +311,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
310
311
  "icon.agents": "👥",
311
312
  "icon.job": "⚙",
312
313
  "icon.cache": "💾",
314
+ "icon.cacheMiss": "⊘",
313
315
  "icon.input": "⤵",
314
316
  "icon.output": "⤴",
315
317
  "icon.host": "🖥",
@@ -579,6 +581,8 @@ const NERD_SYMBOLS: SymbolMap = {
579
581
  "icon.job": "\uf013",
580
582
  // pick:  | alt:  
581
583
  "icon.cache": "\uf1c0",
584
+ // pick: (fa-ban) | alt: ⊘
585
+ "icon.cacheMiss": "\uf05e",
582
586
  // pick:  | alt:  →
583
587
  "icon.input": "\uf090",
584
588
  // pick:  | alt:  →
@@ -810,6 +814,7 @@ const ASCII_SYMBOLS: SymbolMap = {
810
814
  "icon.agents": "AG",
811
815
  "icon.job": "bg",
812
816
  "icon.cache": "cache",
817
+ "icon.cacheMiss": "!",
813
818
  "icon.input": "in:",
814
819
  "icon.output": "out:",
815
820
  "icon.host": "host",
@@ -1711,6 +1716,14 @@ export class Theme {
1711
1716
  bottomRight: this.#symbols["boxRound.bottomRight"],
1712
1717
  horizontal: this.#symbols["boxRound.horizontal"],
1713
1718
  vertical: this.#symbols["boxRound.vertical"],
1719
+ // Junctions have no rounded Unicode variant, so a rounded box reuses the
1720
+ // sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a
1721
+ // theme's `boxSharp.tee*` overrides effective for rounded-box dividers.
1722
+ cross: this.#symbols["boxSharp.cross"],
1723
+ teeDown: this.#symbols["boxSharp.teeDown"],
1724
+ teeUp: this.#symbols["boxSharp.teeUp"],
1725
+ teeRight: this.#symbols["boxSharp.teeRight"],
1726
+ teeLeft: this.#symbols["boxSharp.teeLeft"],
1714
1727
  };
1715
1728
  }
1716
1729
 
@@ -1770,6 +1783,7 @@ export class Theme {
1770
1783
  agents: this.#symbols["icon.agents"],
1771
1784
  job: this.#symbols["icon.job"],
1772
1785
  cache: this.#symbols["icon.cache"],
1786
+ cacheMiss: this.#symbols["icon.cacheMiss"],
1773
1787
  input: this.#symbols["icon.input"],
1774
1788
  output: this.#symbols["icon.output"],
1775
1789
  host: this.#symbols["icon.host"],
@@ -1,6 +1,6 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
3
- import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
3
+ import type { AssistantMessage, ImageContent, Message, Usage, UsageReport } from "@oh-my-pi/pi-ai";
4
4
  import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
5
5
  import type { CollabGuestLink } from "../collab/guest";
6
6
  import type { CollabHost } from "../collab/host";
@@ -159,6 +159,12 @@ export interface InteractiveModeContext {
159
159
  isPythonMode: boolean;
160
160
  streamingComponent: AssistantMessageComponent | undefined;
161
161
  streamingMessage: AssistantMessage | undefined;
162
+ /**
163
+ * Usage of the most recently rendered assistant turn, used to detect a
164
+ * prompt-cache invalidation on the next turn (cache footprint collapse).
165
+ * Reseeded by `renderSessionContext` on every rebuild/session switch.
166
+ */
167
+ lastAssistantUsage: Usage | undefined;
162
168
  loadingAnimation: Loader | undefined;
163
169
  autoCompactionLoader: Loader | undefined;
164
170
  retryLoader: Loader | undefined;
@@ -9,9 +9,10 @@ import { createAdvisorMessageCard } from "../../modes/components/advisor-message
9
9
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
10
10
  import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message";
11
11
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
12
- import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
12
+ import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
13
13
  import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
14
14
  import {
15
+ BranchSummaryMessageComponent,
15
16
  CompactionSummaryMessageComponent,
16
17
  createHandoffSummaryMessageComponent,
17
18
  } from "../../modes/components/compaction-summary-message";
@@ -358,6 +359,9 @@ export class UiHelpers {
358
359
  ): void {
359
360
  // Preserved: message_start handler owns this lifecycle (see #783)
360
361
  this.ctx.pendingTools.clear();
362
+ // Reseed the cache-invalidation baseline: this rebuild re-derives every
363
+ // turn's marker from usage, and the last turn becomes the live baseline.
364
+ this.ctx.lastAssistantUsage = undefined;
361
365
 
362
366
  if (options.updateFooter) {
363
367
  this.ctx.statusLine.invalidate();
@@ -399,13 +403,27 @@ export class UiHelpers {
399
403
  // updateResult armed.
400
404
  previous.seal();
401
405
  };
402
- for (const message of sessionContext.messages) {
406
+ const messages = sessionContext.messages;
407
+ const count = messages.length;
408
+ for (let i = 0; i < count; i++) {
409
+ const message = messages[i]!;
403
410
  if (message.role !== "toolResult") flushPendingUsage();
404
411
  // Assistant messages need special handling for tool calls
405
412
  if (message.role === "assistant") {
406
413
  this.ctx.addMessageToChat(message);
407
414
  const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1];
408
415
  const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
416
+ if (assistantComponent) {
417
+ const usage = message.usage;
418
+ const explained = sessionContext.cacheMissExplainedAt?.[i] ?? false;
419
+ if (this.ctx.settings.get("display.cacheMissMarker") && !explained) {
420
+ const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage);
421
+ if (invalidation) assistantComponent.setCacheInvalidation(invalidation);
422
+ }
423
+ if (usage.cacheRead + usage.cacheWrite + usage.input > 0) {
424
+ this.ctx.lastAssistantUsage = usage;
425
+ }
426
+ }
409
427
  const hasVisibleAssistantContent = message.content.some(
410
428
  content =>
411
429
  (content.type === "text" && canonicalizeMessage(content.text)) ||
@@ -1,8 +1,7 @@
1
1
  <user_interjection>
2
- The user sent this message while you were working on the current task. It takes
3
- priority and supersedes your earlier plan wherever they conflict. Stop work that no
4
- longer matches their intent, re-read the request below, and adjust what you are doing
5
- now.
2
+ The user sent this message as an interjection while you were working. It takes
3
+ priority and supersedes earlier instructions wherever they conflict re-read it
4
+ and make sure your current work reflects their intent.
6
5
 
7
6
  <message>
8
7
  {{message}}
package/src/sdk.ts CHANGED
@@ -838,7 +838,7 @@ export interface BuildSystemPromptOptions {
838
838
  contextFiles?: Array<{ path: string; content: string }>;
839
839
  cwd?: string;
840
840
  appendPrompt?: string;
841
- repeatToolDescriptions?: boolean;
841
+ inlineToolDescriptors?: boolean;
842
842
  }
843
843
 
844
844
  /**
@@ -853,7 +853,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
853
853
  skills: options.skills,
854
854
  contextFiles: options.contextFiles,
855
855
  appendSystemPrompt: options.appendPrompt,
856
- repeatToolDescriptions: options.repeatToolDescriptions,
856
+ inlineToolDescriptors: options.inlineToolDescriptors,
857
857
  });
858
858
  }
859
859
 
@@ -2130,7 +2130,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2130
2130
  emitEvent: event => cursorEventEmitter?.(event),
2131
2131
  });
2132
2132
 
2133
- const repeatToolDescriptions = settings.get("repeatToolDescriptions");
2133
+ const inlineToolDescriptors = settings.get("inlineToolDescriptors");
2134
2134
  const eagerTasks = settings.get("task.eager") !== "default";
2135
2135
  const eagerTasksAlways = settings.get("task.eager") === "always";
2136
2136
  const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined;
@@ -2198,7 +2198,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2198
2198
  }
2199
2199
  appendPrompt = parts.join("\n\n");
2200
2200
  }
2201
- // Owned/in-band tool dialect (non-native) repeats the catalog as `# Tool:`
2201
+ // Owned/in-band tool dialects (non-native) require the catalog as `# Tool:`
2202
2202
  // sections; native tool calling lets the compact name list suffice.
2203
2203
  const nativeTools = resolveDialect(settings.get("tools.format"), agent?.state.model ?? model) === undefined;
2204
2204
  const defaultPrompt = await buildSystemPromptInternal({
@@ -2211,7 +2211,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2211
2211
  alwaysApplyRules,
2212
2212
  skillsSettings: settings.getGroup("skills"),
2213
2213
  appendSystemPrompt: appendPrompt,
2214
- repeatToolDescriptions,
2214
+ inlineToolDescriptors,
2215
2215
  nativeTools,
2216
2216
  intentField,
2217
2217
  mcpDiscoveryMode: hasDiscoverableTools,
@@ -2536,9 +2536,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2536
2536
  return result;
2537
2537
  },
2538
2538
  intentTracing: !!intentField,
2539
+ pruneToolDescriptions: inlineToolDescriptors,
2539
2540
  dialect: resolveDialect(settings.get("tools.format"), model),
2540
2541
  abortOnFabricatedToolResult: settings.get("tools.abortOnFabricatedResult"),
2541
- getToolChoice: () => session?.nextToolChoice(),
2542
+ getToolChoice: () => session?.nextToolChoiceDirective(),
2542
2543
  telemetry: options.telemetry,
2543
2544
  appendOnlyContext: model
2544
2545
  ? shouldEnableAppendOnlyContext(settings.get("provider.appendOnlyContext"), model)
@@ -2606,6 +2607,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2606
2607
  session = new AgentSession({
2607
2608
  advisorWatchdogPrompt,
2608
2609
  agent,
2610
+ pruneToolDescriptions: inlineToolDescriptors,
2609
2611
  thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel,
2610
2612
  sessionManager,
2611
2613
  settings,
@@ -35,6 +35,7 @@ import {
35
35
  countTokens,
36
36
  resolveTelemetry,
37
37
  ThinkingLevel,
38
+ type ToolChoiceDirective,
38
39
  } from "@oh-my-pi/pi-agent-core";
39
40
  import {
40
41
  AGGRESSIVE_SHAKE_CONFIG,
@@ -102,6 +103,7 @@ import {
102
103
  resolveServiceTier,
103
104
  streamSimple,
104
105
  } from "@oh-my-pi/pi-ai";
106
+ import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema";
105
107
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
106
108
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
107
109
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
@@ -260,6 +262,7 @@ import type { CheckpointState } from "../tools/checkpoint";
260
262
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
261
263
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
262
264
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
265
+ import { buildResolveReminderMessage } from "../tools/resolve";
263
266
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
264
267
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
265
268
  import { clampTimeout } from "../tools/tool-timeouts";
@@ -367,6 +370,23 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
367
370
  deferredHandoff: false,
368
371
  continuationScheduled: true,
369
372
  };
373
+
374
+ /**
375
+ * Per-turn prune cache window. A tool result whose all-message suffix exceeds
376
+ * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
377
+ * cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside
378
+ * this tail (matches the supersede pass's default `suffixTokenLimit`); deeper
379
+ * stale/age victims are left to compaction/shake, which rebuild the cache anyway.
380
+ */
381
+ const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000;
382
+
383
+ /**
384
+ * Idle gap after which the supersede pass may flush the whole sent region (the
385
+ * provider cache is cold, so re-writing it is free). MUST exceed the maximum
386
+ * Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a
387
+ * still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL.
388
+ */
389
+ const PRUNE_IDLE_FLUSH_MS = 90 * 60_000;
370
390
  export type CommandMetadataChangedListener = () => void | Promise<void>;
371
391
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
372
392
 
@@ -516,6 +536,12 @@ export interface AgentSessionConfig {
516
536
  advisorReadOnlyTools?: AgentTool[];
517
537
  /** Preloaded watchdog prompt content for the advisor. */
518
538
  advisorWatchdogPrompt?: string;
539
+ /**
540
+ * Strip tool descriptions from provider-bound tool specs on side requests
541
+ * (handoff). Must match the session-start value used to build the system
542
+ * prompt so inline descriptors are not also sent through provider schemas.
543
+ */
544
+ pruneToolDescriptions?: boolean;
519
545
  /**
520
546
  * Disconnect this session's OWNED MCP manager on dispose. Provided only when
521
547
  * the session created the manager (top-level sessions); subagents reuse a
@@ -1305,6 +1331,8 @@ export class AgentSession {
1305
1331
  // unchanged — otherwise a mid-turn estimate would survive into idle.
1306
1332
  #contextUsageRevision = 0;
1307
1333
  #obfuscator: SecretObfuscator | undefined;
1334
+ /** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */
1335
+ #pruneToolDescriptions = false;
1308
1336
  #checkpointState: CheckpointState | undefined = undefined;
1309
1337
  #pendingRewindReport: string | undefined = undefined;
1310
1338
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
@@ -1513,6 +1541,7 @@ export class AgentSession {
1513
1541
  this.#modelRegistry = config.modelRegistry;
1514
1542
  this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1515
1543
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1544
+ this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1516
1545
  this.#validateRetryFallbackChains();
1517
1546
  this.#toolRegistry = config.toolRegistry ?? new Map();
1518
1547
  this.#requestedToolNames = config.requestedToolNames;
@@ -2124,6 +2153,36 @@ export class AgentSession {
2124
2153
  return undefined;
2125
2154
  }
2126
2155
 
2156
+ /**
2157
+ * The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority:
2158
+ * 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) —
2159
+ * consuming, unchanged from `nextToolChoice`;
2160
+ * 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a
2161
+ * PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head
2162
+ * and escalates to a forced `resolve` only if the model declines. A compliant turn
2163
+ * pays ZERO tool_choice change (no prompt-cache messages-cache invalidation);
2164
+ * 3. else undefined.
2165
+ */
2166
+ nextToolChoiceDirective(): ToolChoiceDirective | undefined {
2167
+ const hard = this.nextToolChoice();
2168
+ if (hard !== undefined) return hard;
2169
+ const head = this.#toolChoiceQueue.peekPendingHead();
2170
+ if (head !== undefined) {
2171
+ return {
2172
+ soft: true,
2173
+ id: head.id,
2174
+ toolName: "resolve",
2175
+ reminder: [buildResolveReminderMessage(head.sourceToolName)],
2176
+ };
2177
+ }
2178
+ return undefined;
2179
+ }
2180
+
2181
+ /** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */
2182
+ peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
2183
+ return this.#toolChoiceQueue.peekPendingInvoker();
2184
+ }
2185
+
2127
2186
  /**
2128
2187
  * Force the next model call to target a specific active tool, then terminate
2129
2188
  * the agent loop. Pushes a two-step sequence [forced, "none"] so the model
@@ -4851,7 +4910,7 @@ export class AgentSession {
4851
4910
  * cache per-tool strings without preserving this property.
4852
4911
  *
4853
4912
  * Inputs NOT covered: tool input schemas; memory instructions read from disk;
4854
- * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
4913
+ * and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`,
4855
4914
  * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
4856
4915
  * closure-captured ones cannot change at runtime regardless of skip behavior.
4857
4916
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
@@ -7299,11 +7358,16 @@ export class AgentSession {
7299
7358
 
7300
7359
  async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
7301
7360
  const branchEntries = this.sessionManager.getBranch();
7361
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
7302
7362
  const result = pruneToolOutputs(
7303
7363
  branchEntries,
7304
7364
  this.#withPlanProtection({
7305
7365
  ...DEFAULT_PRUNE_CONFIG,
7306
7366
  pruneUseless: this.settings.getGroup("compaction").dropUseless,
7367
+ // Cache-stable boundary: never re-write the warm, already-sent prefix
7368
+ // (deep stale/age victims) or summarized-away entries every turn.
7369
+ keepBoundaryId,
7370
+ cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS,
7307
7371
  }),
7308
7372
  );
7309
7373
  if (result.prunedCount === 0) {
@@ -7331,12 +7395,17 @@ export class AgentSession {
7331
7395
  const { supersedeReads, dropUseless } = this.settings.getGroup("compaction");
7332
7396
  if (!supersedeReads && !dropUseless) return undefined;
7333
7397
  const branchEntries = this.sessionManager.getBranch();
7398
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
7334
7399
  const result = pruneSupersededToolResults(
7335
7400
  branchEntries,
7336
7401
  this.#withPlanProtection({
7337
7402
  supersedeKey: supersedeReads ? readToolSupersedeKey : undefined,
7338
7403
  pruneUseless: dropUseless,
7339
7404
  protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
7405
+ // Never re-write summarized-away entries; only flush the whole sent
7406
+ // region once the cache is genuinely cold (idle exceeds the 1h TTL).
7407
+ keepBoundaryId,
7408
+ idleFlushMs: PRUNE_IDLE_FLUSH_MS,
7340
7409
  }),
7341
7410
  );
7342
7411
  if (result.prunedCount === 0) {
@@ -7420,8 +7489,14 @@ export class AgentSession {
7420
7489
  return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
7421
7490
  }
7422
7491
 
7423
- const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
7424
- const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
7492
+ const branchEntries = this.sessionManager.getBranch();
7493
+ const config = this.#withPlanProtection({
7494
+ ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
7495
+ // Skip entries summarized away by the latest compaction — shaking them
7496
+ // only churns persisted history with no prompt/cache effect.
7497
+ keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId,
7498
+ });
7499
+ const regions = collectShakeRegions(branchEntries, config);
7425
7500
  if (regions.length === 0) {
7426
7501
  return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
7427
7502
  }
@@ -7598,9 +7673,6 @@ export class AgentSession {
7598
7673
  convertToLlm,
7599
7674
  model: this.model,
7600
7675
  shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7601
- // Providers with hard image caps (OpenRouter: 8) silently drop
7602
- // frames past the cap — keep the archive within budget.
7603
- maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
7604
7676
  });
7605
7677
  const ctxWindow = this.model?.contextWindow ?? 0;
7606
7678
  const budget =
@@ -7849,7 +7921,10 @@ export class AgentSession {
7849
7921
  this.#modelRegistry.resolver(model, this.sessionId),
7850
7922
  {
7851
7923
  systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
7852
- tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
7924
+ tools: obfuscateProviderTools(
7925
+ this.#obfuscator,
7926
+ this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools,
7927
+ ),
7853
7928
  customInstructions: this.#obfuscateTextForProvider(customInstructions),
7854
7929
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
7855
7930
  initiatorOverride: "agent",
@@ -9154,14 +9229,15 @@ export class AgentSession {
9154
9229
  */
9155
9230
  #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
9156
9231
  const archive = snapcompact.getPreservedArchive(result.preserveData);
9157
- const frames = archive ? snapcompact.images(archive) : undefined;
9232
+ const blocks = archive ? snapcompact.historyBlocks(archive) : undefined;
9158
9233
  const summaryMessage = createCompactionSummaryMessage(
9159
9234
  result.summary,
9160
9235
  result.tokensBefore,
9161
9236
  new Date().toISOString(),
9162
9237
  result.shortSummary,
9163
9238
  undefined,
9164
- frames,
9239
+ undefined,
9240
+ blocks,
9165
9241
  );
9166
9242
  let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage);
9167
9243
  for (const message of preparation.recentMessages) {
@@ -9389,15 +9465,15 @@ export class AgentSession {
9389
9465
  let details: unknown;
9390
9466
 
9391
9467
  // Snapcompact runs locally first; if its frame archive plus the kept
9392
- // history still overflows the model window (frames are capped by the
9393
- // image budget and cost ~FRAME_TOKEN_ESTIMATE each), an LLM summary is
9394
- // far cheaper — downgrade to context-full and take the summarizer path.
9468
+ // history still overflows the model window (frames default to
9469
+ // MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM
9470
+ // summary is far cheaper — downgrade to context-full and take the
9471
+ // summarizer path.
9395
9472
  let snapcompactResult: snapcompact.CompactionResult | undefined;
9396
9473
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
9397
9474
  snapcompactResult = await snapcompact.compact(preparation, {
9398
9475
  convertToLlm,
9399
9476
  model: this.model,
9400
- maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
9401
9477
  });
9402
9478
  const ctxWindow = this.model?.contextWindow ?? 0;
9403
9479
  const budget =
@@ -12236,6 +12312,7 @@ export class AgentSession {
12236
12312
  model: this.agent.state.model,
12237
12313
  thinkingLevel: this.#thinkingLevel,
12238
12314
  tools: this.agent.state.tools,
12315
+ inlineToolDescriptors: this.#pruneToolDescriptions,
12239
12316
  });
12240
12317
  }
12241
12318