@oh-my-pi/pi-coding-agent 16.0.10 → 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 (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/cli.js +3344 -3371
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/commit/agentic/agent.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +14 -8
  7. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  9. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  10. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  11. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  13. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  16. package/dist/types/modes/components/index.d.ts +0 -1
  17. package/dist/types/modes/components/message-frame.d.ts +6 -4
  18. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  19. package/dist/types/modes/interactive-mode.d.ts +4 -2
  20. package/dist/types/modes/theme/theme.d.ts +7 -1
  21. package/dist/types/modes/types.d.ts +9 -2
  22. package/dist/types/registry/agent-registry.d.ts +10 -3
  23. package/dist/types/sdk.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +20 -1
  25. package/dist/types/session/compact-modes.d.ts +60 -0
  26. package/dist/types/session/session-context.d.ts +7 -0
  27. package/dist/types/session/session-dump-format.d.ts +1 -0
  28. package/dist/types/session/streaming-output.d.ts +0 -2
  29. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  30. package/dist/types/system-prompt.d.ts +3 -3
  31. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/resolve.d.ts +15 -5
  34. package/package.json +12 -12
  35. package/src/advisor/index.ts +1 -0
  36. package/src/advisor/transcript-recorder.ts +136 -0
  37. package/src/cli/stats-cli.ts +2 -11
  38. package/src/collab/host.ts +25 -13
  39. package/src/commit/agentic/agent.ts +2 -1
  40. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  41. package/src/commit/changelog/index.ts +1 -1
  42. package/src/commit/map-reduce/map-phase.ts +1 -1
  43. package/src/commit/map-reduce/utils.ts +1 -1
  44. package/src/config/settings-schema.ts +16 -9
  45. package/src/config/settings.ts +0 -6
  46. package/src/debug/log-viewer.ts +4 -4
  47. package/src/debug/raw-sse.ts +4 -4
  48. package/src/edit/file-snapshot-store.ts +1 -1
  49. package/src/edit/renderer.ts +9 -9
  50. package/src/eval/js/tool-bridge.ts +3 -2
  51. package/src/eval/py/prelude.py +3 -2
  52. package/src/export/html/tool-views.generated.js +28 -28
  53. package/src/extensibility/extensions/types.ts +7 -0
  54. package/src/hindsight/mental-models.ts +1 -1
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/history-protocol.ts +8 -3
  57. package/src/irc/bus.ts +8 -0
  58. package/src/lsp/index.ts +2 -2
  59. package/src/lsp/render.ts +7 -7
  60. package/src/main.ts +4 -1
  61. package/src/modes/acp/acp-agent.ts +63 -0
  62. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  63. package/src/modes/components/agent-dashboard.ts +1 -1
  64. package/src/modes/components/agent-hub.ts +97 -920
  65. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  66. package/src/modes/components/assistant-message.ts +21 -0
  67. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  68. package/src/modes/components/chat-transcript-builder.ts +476 -0
  69. package/src/modes/components/compaction-summary-message.ts +29 -1
  70. package/src/modes/components/custom-message.ts +4 -1
  71. package/src/modes/components/diff.ts +12 -35
  72. package/src/modes/components/dynamic-border.ts +1 -1
  73. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  74. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  75. package/src/modes/components/hook-selector.ts +2 -2
  76. package/src/modes/components/index.ts +0 -1
  77. package/src/modes/components/message-frame.ts +10 -6
  78. package/src/modes/components/model-selector.ts +2 -2
  79. package/src/modes/components/overlay-box.ts +10 -9
  80. package/src/modes/components/skill-message.ts +39 -19
  81. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  82. package/src/modes/components/welcome.ts +1 -1
  83. package/src/modes/controllers/command-controller.ts +12 -2
  84. package/src/modes/controllers/event-controller.ts +15 -1
  85. package/src/modes/controllers/input-controller.ts +8 -1
  86. package/src/modes/controllers/selector-controller.ts +11 -1
  87. package/src/modes/interactive-mode.ts +13 -3
  88. package/src/modes/theme/theme.ts +14 -0
  89. package/src/modes/types.ts +9 -2
  90. package/src/modes/utils/ui-helpers.ts +20 -2
  91. package/src/prompts/steering/user-interjection.md +3 -4
  92. package/src/prompts/tools/read.md +1 -1
  93. package/src/registry/agent-registry.ts +13 -4
  94. package/src/sdk.ts +9 -7
  95. package/src/session/agent-session.ts +182 -16
  96. package/src/session/compact-modes.ts +105 -0
  97. package/src/session/messages.ts +7 -9
  98. package/src/session/session-context.ts +54 -7
  99. package/src/session/session-dump-format.ts +4 -2
  100. package/src/session/session-history-format.ts +1 -1
  101. package/src/session/snapcompact-inline.ts +2 -2
  102. package/src/session/streaming-output.ts +5 -5
  103. package/src/session/tool-choice-queue.ts +59 -0
  104. package/src/slash-commands/builtin-registry.ts +16 -4
  105. package/src/system-prompt.ts +10 -9
  106. package/src/task/executor.ts +1 -1
  107. package/src/task/output-manager.ts +5 -0
  108. package/src/tools/__tests__/json-tree.test.ts +35 -0
  109. package/src/tools/approval.ts +1 -1
  110. package/src/tools/bash-interactive.ts +4 -4
  111. package/src/tools/bash.ts +0 -1
  112. package/src/tools/browser.ts +0 -1
  113. package/src/tools/eval.ts +1 -1
  114. package/src/tools/gh.ts +1 -1
  115. package/src/tools/index.ts +4 -0
  116. package/src/tools/irc.ts +1 -1
  117. package/src/tools/json-tree.ts +22 -5
  118. package/src/tools/read.ts +5 -6
  119. package/src/tools/resolve.ts +66 -41
  120. package/src/tui/output-block.ts +9 -9
  121. package/src/web/scrapers/firefox-addons.ts +1 -1
  122. package/src/web/scrapers/github.ts +1 -1
  123. package/src/web/scrapers/go-pkg.ts +2 -2
  124. package/src/web/scrapers/metacpan.ts +2 -2
  125. package/src/web/scrapers/nvd.ts +2 -2
  126. package/src/web/scrapers/ollama.ts +1 -1
  127. package/src/web/scrapers/opencorporates.ts +1 -1
  128. package/src/web/scrapers/pub-dev.ts +1 -1
  129. package/src/web/scrapers/repology.ts +1 -1
  130. package/src/web/scrapers/sourcegraph.ts +1 -1
  131. package/src/web/scrapers/terraform.ts +6 -6
  132. package/src/web/scrapers/wikidata.ts +2 -2
  133. package/src/workspace-tree.ts +1 -1
  134. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  135. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -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}}
@@ -33,7 +33,7 @@ Append `:<sel>` to `path`; bare path = default mode.
33
33
  - File with explicit selector → lines prefixed with numbers: `41|def alpha():`.
34
34
  {{/if}}
35
35
  {{/if}}
36
- - Parseable code without selector → **structural summary**: declarations kept, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. <path>:5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content — NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice.
36
+ - Parseable code without selector → **structural summary**: declarations kept, body elided with `…`. The footer shows the recovery selector. Re-issue ONLY the ranges you need via the multi-range selector.
37
37
 
38
38
  # Documents & Notebooks
39
39
 
@@ -22,7 +22,13 @@ export const MAIN_AGENT_ID = "Main";
22
22
  * - `aborted`: hard-killed, terminal.
23
23
  */
24
24
  export type AgentStatus = "running" | "idle" | "parked" | "aborted";
25
- export type AgentKind = "main" | "sub";
25
+ /**
26
+ * - `main`/`sub`: the user-facing agent tree (driving agent + task subagents).
27
+ * - `advisor`: a passive review transcript persisted like a subagent for usage
28
+ * attribution and Agent Hub observability, but never a peer — hidden from
29
+ * agent-facing rosters (`irc`, `history://`) and not messageable/revivable.
30
+ */
31
+ export type AgentKind = "main" | "sub" | "advisor";
26
32
 
27
33
  export interface AgentRef {
28
34
  id: string;
@@ -157,11 +163,14 @@ export class AgentRegistry {
157
163
  }
158
164
 
159
165
  /**
160
- * Returns every alive agent (running | idle) except the caller.
161
- * Flat namespace: every agent can see every other agent.
166
+ * Returns every alive agent (running | idle) except the caller. Advisor refs
167
+ * are observability-only transcripts, never peers, so they are excluded.
168
+ * Flat namespace: every other agent is visible.
162
169
  */
163
170
  listVisibleTo(id: string): AgentRef[] {
164
- return this.list().filter(ref => ref.id !== id && (ref.status === "running" || ref.status === "idle"));
171
+ return this.list().filter(
172
+ ref => ref.id !== id && ref.kind !== "advisor" && (ref.status === "running" || ref.status === "idle"),
173
+ );
165
174
  }
166
175
 
167
176
  onChange(listener: RegistryListener): () => void {
package/src/sdk.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  type AgentTelemetryConfig,
6
6
  type AgentTool,
7
7
  AppendOnlyContextManager,
8
- INTENT_FIELD,
9
8
  type ThinkingLevel,
10
9
  } from "@oh-my-pi/pi-agent-core";
11
10
  import {
@@ -35,6 +34,7 @@ import {
35
34
  prompt,
36
35
  Snowflake,
37
36
  } from "@oh-my-pi/pi-utils";
37
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
38
38
  import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor";
39
39
  import { type AsyncJob, AsyncJobManager } from "./async";
40
40
  import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
@@ -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,
@@ -62,6 +63,7 @@ import {
62
63
  type ShakeRegion,
63
64
  type SummaryOptions,
64
65
  shouldCompact,
66
+ shouldUseOpenAiRemoteCompaction,
65
67
  } from "@oh-my-pi/pi-agent-core/compaction";
66
68
  import {
67
69
  DEFAULT_PRUNE_CONFIG,
@@ -101,6 +103,7 @@ import {
101
103
  resolveServiceTier,
102
104
  streamSimple,
103
105
  } from "@oh-my-pi/pi-ai";
106
+ import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema";
104
107
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
105
108
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
106
109
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
@@ -126,6 +129,7 @@ import {
126
129
  type AdvisorNote,
127
130
  AdvisorRuntime,
128
131
  type AdvisorSeverity,
132
+ AdvisorTranscriptRecorder,
129
133
  formatAdvisorBatchContent,
130
134
  isAdvisorInterruptImmuneTurnActive,
131
135
  isInterruptingSeverity,
@@ -258,6 +262,7 @@ import type { CheckpointState } from "../tools/checkpoint";
258
262
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
259
263
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
260
264
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
265
+ import { buildResolveReminderMessage } from "../tools/resolve";
261
266
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
262
267
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
263
268
  import { clampTimeout } from "../tools/tool-timeouts";
@@ -277,6 +282,7 @@ import {
277
282
  shouldEvaluateCodexAutoRedeem,
278
283
  shouldPromptCodexAutoRedeem,
279
284
  } from "./codex-auto-reset";
285
+ import { findCompactMode } from "./compact-modes";
280
286
  import {
281
287
  type BashExecutionMessage,
282
288
  type CustomMessage,
@@ -364,6 +370,23 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
364
370
  deferredHandoff: false,
365
371
  continuationScheduled: true,
366
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;
367
390
  export type CommandMetadataChangedListener = () => void | Promise<void>;
368
391
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
369
392
 
@@ -513,6 +536,12 @@ export interface AgentSessionConfig {
513
536
  advisorReadOnlyTools?: AgentTool[];
514
537
  /** Preloaded watchdog prompt content for the advisor. */
515
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;
516
545
  /**
517
546
  * Disconnect this session's OWNED MCP manager on dispose. Provided only when
518
547
  * the session created the manager (top-level sessions); subagents reuse a
@@ -1110,6 +1139,13 @@ export class AgentSession {
1110
1139
  #advisorReadOnlyTools?: AgentTool[];
1111
1140
  #advisorWatchdogPrompt?: string;
1112
1141
  #advisorYieldQueueUnsubscribe?: () => void;
1142
+ /** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
1143
+ * attribution and Agent Hub observability. Undefined when no advisor is active. */
1144
+ #advisorTranscriptRecorder?: AdvisorTranscriptRecorder;
1145
+ /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
1146
+ #advisorAgentUnsubscribe?: () => void;
1147
+ /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */
1148
+ #advisorRecorderClosed: Promise<void> = Promise.resolve();
1113
1149
  #goalTurnCounter = 0;
1114
1150
  #planReferenceSent = false;
1115
1151
  #planReferencePath = "local://PLAN.md";
@@ -1295,6 +1331,8 @@ export class AgentSession {
1295
1331
  // unchanged — otherwise a mid-turn estimate would survive into idle.
1296
1332
  #contextUsageRevision = 0;
1297
1333
  #obfuscator: SecretObfuscator | undefined;
1334
+ /** Session-start value of `inlineToolDescriptors`; drives handoff tool pruning. */
1335
+ #pruneToolDescriptions = false;
1298
1336
  #checkpointState: CheckpointState | undefined = undefined;
1299
1337
  #pendingRewindReport: string | undefined = undefined;
1300
1338
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
@@ -1503,6 +1541,7 @@ export class AgentSession {
1503
1541
  this.#modelRegistry = config.modelRegistry;
1504
1542
  this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1505
1543
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1544
+ this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1506
1545
  this.#validateRetryFallbackChains();
1507
1546
  this.#toolRegistry = config.toolRegistry ?? new Map();
1508
1547
  this.#requestedToolNames = config.requestedToolNames;
@@ -1708,7 +1747,13 @@ export class AgentSession {
1708
1747
  * so none of them inject into the new conversation.
1709
1748
  */
1710
1749
  #resetAdvisorSessionState(): void {
1750
+ // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
1751
+ // loop, and that abort can emit an `aborted` message_end we must not attribute to
1752
+ // either session's transcript. Detach, reset, then re-attach the live agent's feed.
1753
+ this.#advisorAgentUnsubscribe?.();
1754
+ this.#advisorAgentUnsubscribe = undefined;
1711
1755
  this.#advisorRuntime?.reset();
1756
+ this.#attachAdvisorRecorderFeed();
1712
1757
  this.#advisorPrimaryTurnsCompleted = 0;
1713
1758
  this.#advisorInterruptImmuneTurnStart = undefined;
1714
1759
  this.#advisorAutoResumeSuppressed = false;
@@ -1841,6 +1886,18 @@ export class AgentSession {
1841
1886
  };
1842
1887
 
1843
1888
  this.#advisorAgent = advisorAgent;
1889
+ // Persist the advisor's turns to `<session>/__advisor.jsonl` (resolved lazily
1890
+ // so it follows session switches) so its model usage is attributed in stats
1891
+ // and its transcript shows in the Agent Hub — without registering it as a peer.
1892
+ const recorder = new AdvisorTranscriptRecorder(
1893
+ () => this.sessionManager.getSessionFile(),
1894
+ () => this.sessionManager.getCwd(),
1895
+ // On the advisor on→off→on toggle, wait for the prior recorder's close so
1896
+ // two SessionManagers never hold the same __advisor.jsonl at once.
1897
+ this.#advisorRecorderClosed,
1898
+ );
1899
+ this.#advisorTranscriptRecorder = recorder;
1900
+ this.#attachAdvisorRecorderFeed();
1844
1901
  this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
1845
1902
  snapshotMessages: () => this.agent.state.messages,
1846
1903
  enqueueAdvice,
@@ -1871,10 +1928,21 @@ export class AgentSession {
1871
1928
  }
1872
1929
 
1873
1930
  #stopAdvisorRuntime(): void {
1931
+ // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
1932
+ // the loop, and an abort emits a final `message_end` we must not enqueue against
1933
+ // a closing recorder (it would reopen and resurrect an already-released file).
1934
+ this.#advisorAgentUnsubscribe?.();
1935
+ this.#advisorAgentUnsubscribe = undefined;
1874
1936
  if (this.#advisorRuntime) {
1875
1937
  this.#advisorRuntime.dispose();
1876
1938
  this.#advisorRuntime = undefined;
1877
1939
  }
1940
+ if (this.#advisorTranscriptRecorder) {
1941
+ // Capture the close so dispose()/`/drop` can await the queued open+append+close —
1942
+ // the last advisor turn would otherwise be lost on a fast process exit.
1943
+ this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close();
1944
+ this.#advisorTranscriptRecorder = undefined;
1945
+ }
1878
1946
  if (this.#advisorAgent) {
1879
1947
  this.#advisorAgent = undefined;
1880
1948
  }
@@ -1882,6 +1950,18 @@ export class AgentSession {
1882
1950
  this.#advisorYieldQueueUnsubscribe = undefined;
1883
1951
  }
1884
1952
 
1953
+ /** Subscribe the advisor agent's finalized messages into the transcript recorder.
1954
+ * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
1955
+ * so the re-prime path can mute the feed across an abort-driven reset. */
1956
+ #attachAdvisorRecorderFeed(): void {
1957
+ const agent = this.#advisorAgent;
1958
+ const recorder = this.#advisorTranscriptRecorder;
1959
+ if (!agent || !recorder) return;
1960
+ this.#advisorAgentUnsubscribe = agent.subscribe(event => {
1961
+ if (event.type === "message_end") recorder.record(event.message);
1962
+ });
1963
+ }
1964
+
1885
1965
  async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
1886
1966
  const promotionSettings = this.settings.getGroup("contextPromotion");
1887
1967
  if (!promotionSettings.enabled) return false;
@@ -2073,6 +2153,36 @@ export class AgentSession {
2073
2153
  return undefined;
2074
2154
  }
2075
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
+
2076
2186
  /**
2077
2187
  * Force the next model call to target a specific active tool, then terminate
2078
2188
  * the agent loop. Pushes a two-step sequence [forced, "none"] so the model
@@ -4060,6 +4170,9 @@ export class AgentSession {
4060
4170
  await shutdownTinyTitleClient();
4061
4171
  this.#releasePowerAssertion();
4062
4172
  await this.sessionManager.close();
4173
+ // beginDispose() stopped the advisor and captured its recorder close; await
4174
+ // it so the final advisor turn is flushed before the process may exit.
4175
+ await this.#advisorRecorderClosed;
4063
4176
  this.#closeAllProviderSessions("dispose");
4064
4177
  // Disconnect the MCP manager this session OWNS so its stdio servers are
4065
4178
  // not orphaned at exit. Best-effort: a failure here must never throw out
@@ -4797,7 +4910,7 @@ export class AgentSession {
4797
4910
  * cache per-tool strings without preserving this property.
4798
4911
  *
4799
4912
  * Inputs NOT covered: tool input schemas; memory instructions read from disk;
4800
- * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
4913
+ * and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`,
4801
4914
  * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
4802
4915
  * closure-captured ones cannot change at runtime regardless of skip behavior.
4803
4916
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
@@ -6597,6 +6710,14 @@ export class AgentSession {
6597
6710
  this.#closeAllProviderSessions("new session");
6598
6711
  this.agent.reset();
6599
6712
  if (options?.drop && previousSessionFile) {
6713
+ // Detach the advisor recorder feed and drain its writer BEFORE deleting the
6714
+ // old artifacts dir: `await this.abort()` only stops the primary, so a still-
6715
+ // running advisor turn could otherwise finish, emit `message_end`, and recreate
6716
+ // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
6717
+ // the advisor and re-attaches the feed at the new session's path.
6718
+ this.#advisorAgentUnsubscribe?.();
6719
+ this.#advisorAgentUnsubscribe = undefined;
6720
+ if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close();
6600
6721
  try {
6601
6722
  await this.sessionManager.dropSession(previousSessionFile);
6602
6723
  } catch (err) {
@@ -7237,11 +7358,16 @@ export class AgentSession {
7237
7358
 
7238
7359
  async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
7239
7360
  const branchEntries = this.sessionManager.getBranch();
7361
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
7240
7362
  const result = pruneToolOutputs(
7241
7363
  branchEntries,
7242
7364
  this.#withPlanProtection({
7243
7365
  ...DEFAULT_PRUNE_CONFIG,
7244
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,
7245
7371
  }),
7246
7372
  );
7247
7373
  if (result.prunedCount === 0) {
@@ -7269,12 +7395,17 @@ export class AgentSession {
7269
7395
  const { supersedeReads, dropUseless } = this.settings.getGroup("compaction");
7270
7396
  if (!supersedeReads && !dropUseless) return undefined;
7271
7397
  const branchEntries = this.sessionManager.getBranch();
7398
+ const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
7272
7399
  const result = pruneSupersededToolResults(
7273
7400
  branchEntries,
7274
7401
  this.#withPlanProtection({
7275
7402
  supersedeKey: supersedeReads ? readToolSupersedeKey : undefined,
7276
7403
  pruneUseless: dropUseless,
7277
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,
7278
7409
  }),
7279
7410
  );
7280
7411
  if (result.prunedCount === 0) {
@@ -7358,8 +7489,14 @@ export class AgentSession {
7358
7489
  return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
7359
7490
  }
7360
7491
 
7361
- const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
7362
- 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);
7363
7500
  if (regions.length === 0) {
7364
7501
  return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
7365
7502
  }
@@ -7433,6 +7570,15 @@ export class AgentSession {
7433
7570
  if (this.#compactionAbortController) {
7434
7571
  throw new Error("Compaction already in progress");
7435
7572
  }
7573
+ // Resolve the `/compact <mode>` subcommand up front so input validation
7574
+ // runs before we disconnect/abort the active agent operation below.
7575
+ const compactMode = options?.mode ? findCompactMode(options.mode) : undefined;
7576
+ // Modes that produce no LLM summary (snapcompact) have nothing to focus.
7577
+ // Reject focus text loudly so programmatic callers don't silently lose
7578
+ // instructions (the slash path pre-validates via parseCompactArgs).
7579
+ if (compactMode?.rejectsFocus && customInstructions) {
7580
+ throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
7581
+ }
7436
7582
  this.#disconnectFromAgent();
7437
7583
  await this.abort({ goalReason: "internal" });
7438
7584
  const compactionAbortController = new AbortController();
@@ -7444,8 +7590,26 @@ export class AgentSession {
7444
7590
  }
7445
7591
 
7446
7592
  const compactionSettings = this.settings.getGroup("compaction");
7593
+ // The `/compact <mode>` override (resolved above) replaces the configured
7594
+ // strategy/remote flags for this one invocation. Merged before
7595
+ // prepareCompaction so the remote gating (preparation.settings.
7596
+ // remoteEnabled/endpoint) and the snapcompact decision below both see it.
7597
+ const effectiveSettings = compactMode
7598
+ ? { ...compactionSettings, ...compactMode.overrides }
7599
+ : compactionSettings;
7600
+ if (compactMode?.requiresRemote) {
7601
+ const remoteReady =
7602
+ Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model);
7603
+ if (!remoteReady) {
7604
+ this.emitNotice(
7605
+ "warning",
7606
+ `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`,
7607
+ "compaction",
7608
+ );
7609
+ }
7610
+ }
7447
7611
  const pathEntries = this.sessionManager.getBranch();
7448
- const preparation = prepareCompaction(pathEntries, compactionSettings);
7612
+ const preparation = prepareCompaction(pathEntries, effectiveSettings);
7449
7613
  if (!preparation) {
7450
7614
  // Check why we can't compact
7451
7615
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -7484,7 +7648,7 @@ export class AgentSession {
7484
7648
  // directed LLM summary; a text-only model cannot read the frames back —
7485
7649
  // both take the summarizer path (the latter loudly).
7486
7650
  const wantsSnapcompact =
7487
- compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
7651
+ compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
7488
7652
  const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
7489
7653
  if (wantsSnapcompact && !snapcompactReady) {
7490
7654
  this.emitNotice(
@@ -7509,14 +7673,11 @@ export class AgentSession {
7509
7673
  convertToLlm,
7510
7674
  model: this.model,
7511
7675
  shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7512
- // Providers with hard image caps (OpenRouter: 8) silently drop
7513
- // frames past the cap — keep the archive within budget.
7514
- maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
7515
7676
  });
7516
7677
  const ctxWindow = this.model?.contextWindow ?? 0;
7517
7678
  const budget =
7518
7679
  ctxWindow > 0
7519
- ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
7680
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7520
7681
  : Number.POSITIVE_INFINITY;
7521
7682
  if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7522
7683
  logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
@@ -7760,7 +7921,10 @@ export class AgentSession {
7760
7921
  this.#modelRegistry.resolver(model, this.sessionId),
7761
7922
  {
7762
7923
  systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
7763
- 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
+ ),
7764
7928
  customInstructions: this.#obfuscateTextForProvider(customInstructions),
7765
7929
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
7766
7930
  initiatorOverride: "agent",
@@ -9065,14 +9229,15 @@ export class AgentSession {
9065
9229
  */
9066
9230
  #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
9067
9231
  const archive = snapcompact.getPreservedArchive(result.preserveData);
9068
- const frames = archive ? snapcompact.images(archive) : undefined;
9232
+ const blocks = archive ? snapcompact.historyBlocks(archive) : undefined;
9069
9233
  const summaryMessage = createCompactionSummaryMessage(
9070
9234
  result.summary,
9071
9235
  result.tokensBefore,
9072
9236
  new Date().toISOString(),
9073
9237
  result.shortSummary,
9074
9238
  undefined,
9075
- frames,
9239
+ undefined,
9240
+ blocks,
9076
9241
  );
9077
9242
  let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage);
9078
9243
  for (const message of preparation.recentMessages) {
@@ -9300,15 +9465,15 @@ export class AgentSession {
9300
9465
  let details: unknown;
9301
9466
 
9302
9467
  // Snapcompact runs locally first; if its frame archive plus the kept
9303
- // history still overflows the model window (frames are capped by the
9304
- // image budget and cost ~FRAME_TOKEN_ESTIMATE each), an LLM summary is
9305
- // 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.
9306
9472
  let snapcompactResult: snapcompact.CompactionResult | undefined;
9307
9473
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
9308
9474
  snapcompactResult = await snapcompact.compact(preparation, {
9309
9475
  convertToLlm,
9310
9476
  model: this.model,
9311
- maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
9312
9477
  });
9313
9478
  const ctxWindow = this.model?.contextWindow ?? 0;
9314
9479
  const budget =
@@ -12147,6 +12312,7 @@ export class AgentSession {
12147
12312
  model: this.agent.state.model,
12148
12313
  thinkingLevel: this.#thinkingLevel,
12149
12314
  tools: this.agent.state.tools,
12315
+ inlineToolDescriptors: this.#pruneToolDescriptions,
12150
12316
  });
12151
12317
  }
12152
12318