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

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 (110) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli.js +3402 -3443
  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/collab/host.d.ts +2 -2
  6. package/dist/types/collab/protocol.d.ts +4 -5
  7. package/dist/types/commit/agentic/agent.d.ts +1 -1
  8. package/dist/types/config/model-resolver.d.ts +11 -2
  9. package/dist/types/config/settings-schema.d.ts +12 -6
  10. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  13. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/types.d.ts +2 -1
  18. package/dist/types/registry/agent-registry.d.ts +10 -3
  19. package/dist/types/session/agent-session.d.ts +13 -0
  20. package/dist/types/session/compact-modes.d.ts +60 -0
  21. package/dist/types/session/streaming-output.d.ts +0 -2
  22. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  23. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  24. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  25. package/dist/types/tools/index.d.ts +9 -1
  26. package/dist/types/utils/image-loading.d.ts +12 -0
  27. package/dist/types/utils/qrcode.d.ts +48 -0
  28. package/package.json +12 -12
  29. package/src/advisor/index.ts +1 -0
  30. package/src/advisor/transcript-recorder.ts +136 -0
  31. package/src/cli/args.ts +7 -1
  32. package/src/cli/stats-cli.ts +2 -11
  33. package/src/collab/host.ts +29 -17
  34. package/src/collab/protocol.ts +48 -15
  35. package/src/commit/agentic/agent.ts +2 -1
  36. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  37. package/src/commit/changelog/index.ts +1 -1
  38. package/src/commit/map-reduce/map-phase.ts +1 -1
  39. package/src/commit/map-reduce/utils.ts +1 -1
  40. package/src/config/config-file.ts +1 -1
  41. package/src/config/keybindings.ts +2 -2
  42. package/src/config/model-registry.ts +16 -4
  43. package/src/config/model-resolver.ts +193 -35
  44. package/src/config/settings-schema.ts +14 -7
  45. package/src/config/settings.ts +3 -9
  46. package/src/edit/file-snapshot-store.ts +1 -1
  47. package/src/edit/renderer.ts +7 -7
  48. package/src/eval/js/tool-bridge.ts +3 -2
  49. package/src/eval/py/prelude.py +3 -2
  50. package/src/export/html/tool-views.generated.js +28 -28
  51. package/src/extensibility/extensions/types.ts +7 -0
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.txt +1 -1
  54. package/src/internal-urls/history-protocol.ts +8 -3
  55. package/src/irc/bus.ts +8 -0
  56. package/src/lsp/index.ts +2 -2
  57. package/src/main.ts +6 -3
  58. package/src/modes/acp/acp-agent.ts +63 -0
  59. package/src/modes/components/agent-hub.ts +97 -920
  60. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  61. package/src/modes/components/chat-transcript-builder.ts +462 -0
  62. package/src/modes/components/diff.ts +12 -35
  63. package/src/modes/components/oauth-selector.ts +31 -2
  64. package/src/modes/controllers/command-controller.ts +12 -2
  65. package/src/modes/controllers/event-controller.ts +1 -1
  66. package/src/modes/controllers/input-controller.ts +8 -1
  67. package/src/modes/controllers/selector-controller.ts +4 -1
  68. package/src/modes/interactive-mode.ts +4 -2
  69. package/src/modes/types.ts +2 -1
  70. package/src/prompts/tools/inspect-image.md +1 -1
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/registry/agent-registry.ts +13 -4
  73. package/src/sdk.ts +27 -8
  74. package/src/session/agent-session.ts +185 -17
  75. package/src/session/compact-modes.ts +105 -0
  76. package/src/session/session-dump-format.ts +1 -1
  77. package/src/session/session-history-format.ts +1 -1
  78. package/src/session/streaming-output.ts +5 -5
  79. package/src/slash-commands/builtin-registry.ts +45 -15
  80. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  81. package/src/task/executor.ts +1 -1
  82. package/src/task/output-manager.ts +5 -0
  83. package/src/thinking.ts +25 -5
  84. package/src/tools/__tests__/json-tree.test.ts +35 -0
  85. package/src/tools/approval.ts +1 -1
  86. package/src/tools/bash.ts +0 -1
  87. package/src/tools/browser.ts +0 -1
  88. package/src/tools/eval.ts +1 -1
  89. package/src/tools/gh.ts +1 -1
  90. package/src/tools/index.ts +10 -1
  91. package/src/tools/inspect-image.ts +72 -9
  92. package/src/tools/irc.ts +1 -1
  93. package/src/tools/json-tree.ts +22 -5
  94. package/src/tools/read.ts +5 -6
  95. package/src/utils/file-mentions.ts +5 -2
  96. package/src/utils/image-loading.ts +58 -0
  97. package/src/utils/qrcode.ts +535 -0
  98. package/src/web/scrapers/firefox-addons.ts +1 -1
  99. package/src/web/scrapers/github.ts +1 -1
  100. package/src/web/scrapers/go-pkg.ts +2 -2
  101. package/src/web/scrapers/metacpan.ts +2 -2
  102. package/src/web/scrapers/nvd.ts +2 -2
  103. package/src/web/scrapers/ollama.ts +1 -1
  104. package/src/web/scrapers/opencorporates.ts +1 -1
  105. package/src/web/scrapers/pub-dev.ts +1 -1
  106. package/src/web/scrapers/repology.ts +1 -1
  107. package/src/web/scrapers/sourcegraph.ts +1 -1
  108. package/src/web/scrapers/terraform.ts +6 -6
  109. package/src/web/scrapers/wikidata.ts +2 -2
  110. package/src/workspace-tree.ts +1 -1
@@ -62,6 +62,7 @@ import {
62
62
  type ShakeRegion,
63
63
  type SummaryOptions,
64
64
  shouldCompact,
65
+ shouldUseOpenAiRemoteCompaction,
65
66
  } from "@oh-my-pi/pi-agent-core/compaction";
66
67
  import {
67
68
  DEFAULT_PRUNE_CONFIG,
@@ -116,6 +117,7 @@ import {
116
117
  prompt,
117
118
  relativePathWithinRoot,
118
119
  Snowflake,
120
+ withTimeout,
119
121
  } from "@oh-my-pi/pi-utils";
120
122
  import * as snapcompact from "@oh-my-pi/snapcompact";
121
123
  import {
@@ -125,6 +127,7 @@ import {
125
127
  type AdvisorNote,
126
128
  AdvisorRuntime,
127
129
  type AdvisorSeverity,
130
+ AdvisorTranscriptRecorder,
128
131
  formatAdvisorBatchContent,
129
132
  isAdvisorInterruptImmuneTurnActive,
130
133
  isInterruptingSeverity,
@@ -235,6 +238,7 @@ import {
235
238
  AUTO_THINKING,
236
239
  type ConfiguredThinkingLevel,
237
240
  clampAutoThinkingEffort,
241
+ parseConfiguredThinkingLevel,
238
242
  resolveProvisionalAutoLevel,
239
243
  resolveThinkingLevelForModel,
240
244
  shouldDisableReasoning,
@@ -275,6 +279,7 @@ import {
275
279
  shouldEvaluateCodexAutoRedeem,
276
280
  shouldPromptCodexAutoRedeem,
277
281
  } from "./codex-auto-reset";
282
+ import { findCompactMode } from "./compact-modes";
278
283
  import {
279
284
  type BashExecutionMessage,
280
285
  type CustomMessage,
@@ -511,6 +516,13 @@ export interface AgentSessionConfig {
511
516
  advisorReadOnlyTools?: AgentTool[];
512
517
  /** Preloaded watchdog prompt content for the advisor. */
513
518
  advisorWatchdogPrompt?: string;
519
+ /**
520
+ * Disconnect this session's OWNED MCP manager on dispose. Provided only when
521
+ * the session created the manager (top-level sessions); subagents reuse a
522
+ * parent's manager via `options.mcpManager` and omit this so a child's
523
+ * teardown never tears down the shared servers.
524
+ */
525
+ disconnectOwnedMcpManager?: () => Promise<void>;
514
526
  }
515
527
 
516
528
  /** Options for AgentSession.prompt() */
@@ -664,10 +676,16 @@ interface ActiveRetryFallbackState {
664
676
  pinned: boolean;
665
677
  }
666
678
 
667
- function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
679
+ function parseRetryFallbackSelector(
680
+ selector: string,
681
+ modelLookup?: { find(provider: string, id: string): Model | undefined },
682
+ ): RetryFallbackSelector | undefined {
668
683
  const trimmed = selector.trim();
669
684
  if (!trimmed) return undefined;
670
- const parsed = parseModelString(trimmed);
685
+ const parsed = parseModelString(trimmed, {
686
+ allowMaxAlias: true,
687
+ isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
688
+ });
671
689
  if (!parsed) return undefined;
672
690
  return {
673
691
  raw: trimmed,
@@ -1095,6 +1113,13 @@ export class AgentSession {
1095
1113
  #advisorReadOnlyTools?: AgentTool[];
1096
1114
  #advisorWatchdogPrompt?: string;
1097
1115
  #advisorYieldQueueUnsubscribe?: () => void;
1116
+ /** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
1117
+ * attribution and Agent Hub observability. Undefined when no advisor is active. */
1118
+ #advisorTranscriptRecorder?: AdvisorTranscriptRecorder;
1119
+ /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
1120
+ #advisorAgentUnsubscribe?: () => void;
1121
+ /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */
1122
+ #advisorRecorderClosed: Promise<void> = Promise.resolve();
1098
1123
  #goalTurnCounter = 0;
1099
1124
  #planReferenceSent = false;
1100
1125
  #planReferencePath = "local://PLAN.md";
@@ -1195,6 +1220,7 @@ export class AgentSession {
1195
1220
  | undefined;
1196
1221
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1197
1222
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1223
+ #disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
1198
1224
  #requestedToolNames: ReadonlySet<string> | undefined;
1199
1225
  #baseSystemPrompt: string[];
1200
1226
  /**
@@ -1561,6 +1587,7 @@ export class AgentSession {
1561
1587
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1562
1588
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1563
1589
  this.#reloadSshTool = config.reloadSshTool;
1590
+ this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
1564
1591
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1565
1592
  this.#promptModelKey = this.#currentPromptModelKey();
1566
1593
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
@@ -1691,7 +1718,13 @@ export class AgentSession {
1691
1718
  * so none of them inject into the new conversation.
1692
1719
  */
1693
1720
  #resetAdvisorSessionState(): void {
1721
+ // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
1722
+ // loop, and that abort can emit an `aborted` message_end we must not attribute to
1723
+ // either session's transcript. Detach, reset, then re-attach the live agent's feed.
1724
+ this.#advisorAgentUnsubscribe?.();
1725
+ this.#advisorAgentUnsubscribe = undefined;
1694
1726
  this.#advisorRuntime?.reset();
1727
+ this.#attachAdvisorRecorderFeed();
1695
1728
  this.#advisorPrimaryTurnsCompleted = 0;
1696
1729
  this.#advisorInterruptImmuneTurnStart = undefined;
1697
1730
  this.#advisorAutoResumeSuppressed = false;
@@ -1824,6 +1857,18 @@ export class AgentSession {
1824
1857
  };
1825
1858
 
1826
1859
  this.#advisorAgent = advisorAgent;
1860
+ // Persist the advisor's turns to `<session>/__advisor.jsonl` (resolved lazily
1861
+ // so it follows session switches) so its model usage is attributed in stats
1862
+ // and its transcript shows in the Agent Hub — without registering it as a peer.
1863
+ const recorder = new AdvisorTranscriptRecorder(
1864
+ () => this.sessionManager.getSessionFile(),
1865
+ () => this.sessionManager.getCwd(),
1866
+ // On the advisor on→off→on toggle, wait for the prior recorder's close so
1867
+ // two SessionManagers never hold the same __advisor.jsonl at once.
1868
+ this.#advisorRecorderClosed,
1869
+ );
1870
+ this.#advisorTranscriptRecorder = recorder;
1871
+ this.#attachAdvisorRecorderFeed();
1827
1872
  this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
1828
1873
  snapshotMessages: () => this.agent.state.messages,
1829
1874
  enqueueAdvice,
@@ -1854,10 +1899,21 @@ export class AgentSession {
1854
1899
  }
1855
1900
 
1856
1901
  #stopAdvisorRuntime(): void {
1902
+ // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
1903
+ // the loop, and an abort emits a final `message_end` we must not enqueue against
1904
+ // a closing recorder (it would reopen and resurrect an already-released file).
1905
+ this.#advisorAgentUnsubscribe?.();
1906
+ this.#advisorAgentUnsubscribe = undefined;
1857
1907
  if (this.#advisorRuntime) {
1858
1908
  this.#advisorRuntime.dispose();
1859
1909
  this.#advisorRuntime = undefined;
1860
1910
  }
1911
+ if (this.#advisorTranscriptRecorder) {
1912
+ // Capture the close so dispose()/`/drop` can await the queued open+append+close —
1913
+ // the last advisor turn would otherwise be lost on a fast process exit.
1914
+ this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close();
1915
+ this.#advisorTranscriptRecorder = undefined;
1916
+ }
1861
1917
  if (this.#advisorAgent) {
1862
1918
  this.#advisorAgent = undefined;
1863
1919
  }
@@ -1865,6 +1921,18 @@ export class AgentSession {
1865
1921
  this.#advisorYieldQueueUnsubscribe = undefined;
1866
1922
  }
1867
1923
 
1924
+ /** Subscribe the advisor agent's finalized messages into the transcript recorder.
1925
+ * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
1926
+ * so the re-prime path can mute the feed across an abort-driven reset. */
1927
+ #attachAdvisorRecorderFeed(): void {
1928
+ const agent = this.#advisorAgent;
1929
+ const recorder = this.#advisorTranscriptRecorder;
1930
+ if (!agent || !recorder) return;
1931
+ this.#advisorAgentUnsubscribe = agent.subscribe(event => {
1932
+ if (event.type === "message_end") recorder.record(event.message);
1933
+ });
1934
+ }
1935
+
1868
1936
  async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
1869
1937
  const promotionSettings = this.settings.getGroup("contextPromotion");
1870
1938
  if (!promotionSettings.enabled) return false;
@@ -4043,7 +4111,34 @@ export class AgentSession {
4043
4111
  await shutdownTinyTitleClient();
4044
4112
  this.#releasePowerAssertion();
4045
4113
  await this.sessionManager.close();
4114
+ // beginDispose() stopped the advisor and captured its recorder close; await
4115
+ // it so the final advisor turn is flushed before the process may exit.
4116
+ await this.#advisorRecorderClosed;
4046
4117
  this.#closeAllProviderSessions("dispose");
4118
+ // Disconnect the MCP manager this session OWNS so its stdio servers are
4119
+ // not orphaned at exit. Best-effort: a failure here must never throw out
4120
+ // of dispose. Only owning (top-level) sessions provide this callback;
4121
+ // subagents reuse a parent's manager and must not tear it down. Idempotent
4122
+ // with the deferred-discovery disconnect in `createAgentSession`.
4123
+ //
4124
+ // BOUNDED: an owned manager may hold an HTTP/SSE server whose session-
4125
+ // termination DELETE blocks up to the MCP request timeout (30s default,
4126
+ // unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()`
4127
+ // unbounded would stall /exit and print-mode shutdown on a broken remote
4128
+ // endpoint. Race it against a short deadline — stdio close (the subprocess
4129
+ // reap this targets) completes well within the bound; a slow transport
4130
+ // close is left to finish detached. Mirrors the bounded async-job teardown.
4131
+ if (this.#disconnectOwnedMcpManager) {
4132
+ try {
4133
+ await withTimeout(
4134
+ this.#disconnectOwnedMcpManager(),
4135
+ 3_000,
4136
+ "Timed out disconnecting owned MCP manager during dispose",
4137
+ );
4138
+ } catch (error) {
4139
+ logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
4140
+ }
4141
+ }
4047
4142
  // Flush the retain queue BEFORE clearing the session's pointer so
4048
4143
  // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
4049
4144
  // Reversed, the spliced batch survives just long enough to fail the
@@ -4938,6 +5033,24 @@ export class AgentSession {
4938
5033
  return this.agent.state.messages;
4939
5034
  }
4940
5035
 
5036
+ /** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */
5037
+ getImageAttachments(): { label: string; uri: string; image: ImageContent }[] {
5038
+ for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
5039
+ const message = this.agent.state.messages[i];
5040
+ if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) {
5041
+ continue;
5042
+ }
5043
+ const images = message.content.filter((part): part is ImageContent => part.type === "image");
5044
+ if (images.length === 0) continue;
5045
+ return images.map((image, index) => ({
5046
+ label: `Image #${index + 1}`,
5047
+ uri: `attachment://${index + 1}`,
5048
+ image,
5049
+ }));
5050
+ }
5051
+ return [];
5052
+ }
5053
+
4941
5054
  buildDisplaySessionContext(): SessionContext {
4942
5055
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
4943
5056
  }
@@ -6538,6 +6651,14 @@ export class AgentSession {
6538
6651
  this.#closeAllProviderSessions("new session");
6539
6652
  this.agent.reset();
6540
6653
  if (options?.drop && previousSessionFile) {
6654
+ // Detach the advisor recorder feed and drain its writer BEFORE deleting the
6655
+ // old artifacts dir: `await this.abort()` only stops the primary, so a still-
6656
+ // running advisor turn could otherwise finish, emit `message_end`, and recreate
6657
+ // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
6658
+ // the advisor and re-attaches the feed at the new session's path.
6659
+ this.#advisorAgentUnsubscribe?.();
6660
+ this.#advisorAgentUnsubscribe = undefined;
6661
+ if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close();
6541
6662
  try {
6542
6663
  await this.sessionManager.dropSession(previousSessionFile);
6543
6664
  } catch (err) {
@@ -7374,6 +7495,15 @@ export class AgentSession {
7374
7495
  if (this.#compactionAbortController) {
7375
7496
  throw new Error("Compaction already in progress");
7376
7497
  }
7498
+ // Resolve the `/compact <mode>` subcommand up front so input validation
7499
+ // runs before we disconnect/abort the active agent operation below.
7500
+ const compactMode = options?.mode ? findCompactMode(options.mode) : undefined;
7501
+ // Modes that produce no LLM summary (snapcompact) have nothing to focus.
7502
+ // Reject focus text loudly so programmatic callers don't silently lose
7503
+ // instructions (the slash path pre-validates via parseCompactArgs).
7504
+ if (compactMode?.rejectsFocus && customInstructions) {
7505
+ throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
7506
+ }
7377
7507
  this.#disconnectFromAgent();
7378
7508
  await this.abort({ goalReason: "internal" });
7379
7509
  const compactionAbortController = new AbortController();
@@ -7385,8 +7515,26 @@ export class AgentSession {
7385
7515
  }
7386
7516
 
7387
7517
  const compactionSettings = this.settings.getGroup("compaction");
7518
+ // The `/compact <mode>` override (resolved above) replaces the configured
7519
+ // strategy/remote flags for this one invocation. Merged before
7520
+ // prepareCompaction so the remote gating (preparation.settings.
7521
+ // remoteEnabled/endpoint) and the snapcompact decision below both see it.
7522
+ const effectiveSettings = compactMode
7523
+ ? { ...compactionSettings, ...compactMode.overrides }
7524
+ : compactionSettings;
7525
+ if (compactMode?.requiresRemote) {
7526
+ const remoteReady =
7527
+ Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model);
7528
+ if (!remoteReady) {
7529
+ this.emitNotice(
7530
+ "warning",
7531
+ `remote compaction is unavailable for ${this.model.id} (no remote endpoint configured) — using a local summary instead`,
7532
+ "compaction",
7533
+ );
7534
+ }
7535
+ }
7388
7536
  const pathEntries = this.sessionManager.getBranch();
7389
- const preparation = prepareCompaction(pathEntries, compactionSettings);
7537
+ const preparation = prepareCompaction(pathEntries, effectiveSettings);
7390
7538
  if (!preparation) {
7391
7539
  // Check why we can't compact
7392
7540
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -7425,7 +7573,7 @@ export class AgentSession {
7425
7573
  // directed LLM summary; a text-only model cannot read the frames back —
7426
7574
  // both take the summarizer path (the latter loudly).
7427
7575
  const wantsSnapcompact =
7428
- compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
7576
+ compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
7429
7577
  const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
7430
7578
  if (wantsSnapcompact && !snapcompactReady) {
7431
7579
  this.emitNotice(
@@ -7457,7 +7605,7 @@ export class AgentSession {
7457
7605
  const ctxWindow = this.model?.contextWindow ?? 0;
7458
7606
  const budget =
7459
7607
  ctxWindow > 0
7460
- ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
7608
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7461
7609
  : Number.POSITIVE_INFINITY;
7462
7610
  if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7463
7611
  logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
@@ -7729,7 +7877,17 @@ export class AgentSession {
7729
7877
  await this.sessionManager.flush();
7730
7878
  this.#cancelOwnAsyncJobs();
7731
7879
  await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
7880
+ // agent.reset() clears the core steering/follow-up queues. Preserve any queued
7881
+ // steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
7882
+ // pre-loader TUI steer) so they survive into the post-handoff session instead of
7883
+ // being silently dropped. Capture is synchronous immediately before reset and
7884
+ // restore is synchronous immediately after — no await gap — so a steer arriving
7885
+ // later (during ensureOnDisk/Bun.write below) appends to the restored queue
7886
+ // rather than being clobbered.
7887
+ const preservedSteering = this.agent.peekSteeringQueue().slice();
7888
+ const preservedFollowUp = this.agent.peekFollowUpQueue().slice();
7732
7889
  this.agent.reset();
7890
+ this.agent.replaceQueues(preservedSteering, preservedFollowUp);
7733
7891
  this.#freshProviderSessionId = undefined;
7734
7892
  this.#syncAgentSessionId();
7735
7893
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -8785,14 +8943,20 @@ export class AgentSession {
8785
8943
  const existingRoleValue = this.settings.getModelRole(role);
8786
8944
  if (!existingRoleValue) return modelKey;
8787
8945
 
8788
- const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
8946
+ const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, {
8947
+ isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined,
8948
+ });
8789
8949
  return formatModelSelectorValue(modelKey, thinkingLevel);
8790
8950
  }
8791
8951
  #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
8792
8952
  const configuredTarget = currentModel.contextPromotionTarget?.trim();
8793
8953
  if (!configuredTarget) return undefined;
8794
8954
 
8795
- const parsed = parseModelString(configuredTarget);
8955
+ const parsed = parseModelString(configuredTarget, {
8956
+ allowMaxAlias: true,
8957
+ isLiteralModelId: (provider, id) =>
8958
+ availableModels.some(model => model.provider === provider && model.id === id),
8959
+ });
8796
8960
  if (parsed) {
8797
8961
  const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
8798
8962
  if (explicitModel) return explicitModel;
@@ -9087,7 +9251,6 @@ export class AgentSession {
9087
9251
  );
9088
9252
  }
9089
9253
  }
9090
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9091
9254
  // Abort any older auto-compaction before installing this run's controller.
9092
9255
  this.#autoCompactionAbortController?.abort();
9093
9256
  const autoCompactionAbortController = new AbortController();
@@ -9095,11 +9258,16 @@ export class AgentSession {
9095
9258
  const autoCompactionSignal = autoCompactionAbortController.signal;
9096
9259
 
9097
9260
  try {
9261
+ // Emit start AFTER the controller is installed so isCompacting is already true
9262
+ // for any listener — and for input routed during this emit's event-loop yield:
9263
+ // a message typed as the compaction loader appears must land in the compaction
9264
+ // queue, not the core steering queue (which handoff's agent.reset() would wipe).
9265
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9098
9266
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
9099
9267
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
9100
9268
  const handoffResult = await this.handoff(handoffFocus, {
9101
9269
  autoTriggered: true,
9102
- signal: this.#autoCompactionAbortController.signal,
9270
+ signal: autoCompactionSignal,
9103
9271
  });
9104
9272
  if (!handoffResult) {
9105
9273
  const aborted = autoCompactionSignal.aborted;
@@ -9531,12 +9699,12 @@ export class AgentSession {
9531
9699
  triggerContextTokens?: number,
9532
9700
  ): Promise<CompactionCheckResult | "fallback"> {
9533
9701
  const action = "shake";
9534
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9535
9702
  this.#autoCompactionAbortController?.abort();
9536
9703
  const controller = new AbortController();
9537
9704
  this.#autoCompactionAbortController = controller;
9538
9705
  const signal = controller.signal;
9539
9706
  try {
9707
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9540
9708
  const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
9541
9709
  if (signal.aborted) {
9542
9710
  await this.#emitSessionEvent({
@@ -9834,7 +10002,7 @@ export class AgentSession {
9834
10002
  this.configWarnings.push(msg);
9835
10003
  continue;
9836
10004
  }
9837
- const parsed = parseRetryFallbackSelector(selectorStr);
10005
+ const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
9838
10006
  if (!parsed) {
9839
10007
  const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
9840
10008
  logger.warn(msg);
@@ -9857,7 +10025,7 @@ export class AgentSession {
9857
10025
 
9858
10026
  #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
9859
10027
  const configuredSelector = this.settings.getModelRole(role);
9860
- return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
10028
+ return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
9861
10029
  }
9862
10030
 
9863
10031
  #clearActiveRetryFallback(): void {
@@ -9878,7 +10046,7 @@ export class AgentSession {
9878
10046
  }
9879
10047
 
9880
10048
  #resolveRetryFallbackRole(currentSelector: string): string | undefined {
9881
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
10049
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
9882
10050
  if (!parsedCurrent) return undefined;
9883
10051
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9884
10052
  const currentPlainSelector = this.model
@@ -9910,7 +10078,7 @@ export class AgentSession {
9910
10078
  const chain = [primarySelector];
9911
10079
  const seen = new Set<string>([primarySelector.raw]);
9912
10080
  for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
9913
- const parsed = parseRetryFallbackSelector(selector);
10081
+ const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
9914
10082
  if (!parsed || seen.has(parsed.raw)) continue;
9915
10083
  seen.add(parsed.raw);
9916
10084
  chain.push(parsed);
@@ -9921,7 +10089,7 @@ export class AgentSession {
9921
10089
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
9922
10090
  const chain = this.#getRetryFallbackEffectiveChain(role);
9923
10091
  if (chain.length <= 1) return [];
9924
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
10092
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
9925
10093
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9926
10094
  const currentPlainSelector =
9927
10095
  this.model && parsedCurrent
@@ -10018,7 +10186,7 @@ export class AgentSession {
10018
10186
  originalThinkingLevel,
10019
10187
  lastAppliedFallbackThinkingLevel,
10020
10188
  } = this.#activeRetryFallback;
10021
- const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
10189
+ const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry);
10022
10190
  if (!originalSelector) {
10023
10191
  this.#clearActiveRetryFallback();
10024
10192
  return;
@@ -11070,7 +11238,7 @@ export class AgentSession {
11070
11238
  const hasServiceTierEntry = this.sessionManager
11071
11239
  .getBranch()
11072
11240
  .some(entry => entry.type === "service_tier_change");
11073
- const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
11241
+ const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
11074
11242
  const configuredServiceTier = this.settings.get("serviceTier");
11075
11243
  // Session log entries store only concrete levels. When `auto` has resolved
11076
11244
  // for a turn, the persisted context may already carry that concrete level
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Manual `/compact` subcommands. Kept in a dependency-free leaf module so the
3
+ * slash-command registry, the interactive controllers, and `AgentSession`
4
+ * can all import the mode metadata + parser without pulling in the heavy
5
+ * `agent-session` module graph (which would form an import cycle through the
6
+ * slash-command registry) — same rationale as `shake-types.ts`.
7
+ *
8
+ * Each mode is a one-off override layered on top of the configured
9
+ * `compaction.*` settings for a single invocation; it never mutates settings.
10
+ * Adding a mode is a single entry here: the command surface (autocomplete +
11
+ * ACP hint), the parser, and the engine override all read this table.
12
+ */
13
+
14
+ /** Subcommand selecting a one-off compaction mode for manual `/compact`. */
15
+ export type CompactMode = "soft" | "remote" | "snapcompact";
16
+
17
+ /**
18
+ * Per-invocation overrides merged over the configured `compaction.*` settings.
19
+ * Narrowed to the two knobs the modes actually flip; the result stays
20
+ * assignable to the full `CompactionSettings`.
21
+ */
22
+ export interface CompactionOverride {
23
+ strategy?: "context-full" | "snapcompact";
24
+ remoteEnabled?: boolean;
25
+ }
26
+
27
+ export interface CompactModeDef {
28
+ readonly name: CompactMode;
29
+ /** One-line description surfaced in autocomplete + help. */
30
+ readonly description: string;
31
+ /** Settings overrides applied on top of `compaction.*` for this run. */
32
+ readonly overrides: CompactionOverride;
33
+ /**
34
+ * When true, the mode produces no LLM summary, so trailing focus text is
35
+ * meaningless and rejected by the parser (snapcompact archives history into
36
+ * images without a directed summary).
37
+ */
38
+ readonly rejectsFocus?: boolean;
39
+ /**
40
+ * When true, the mode explicitly demands a remote path; the engine warns and
41
+ * falls back to a local summary if neither a remote endpoint nor a
42
+ * provider-native compaction path is available.
43
+ */
44
+ readonly requiresRemote?: boolean;
45
+ }
46
+
47
+ export const COMPACT_MODES: readonly CompactModeDef[] = [
48
+ {
49
+ name: "soft",
50
+ description: "Summarize locally with the active model (skip remote endpoints)",
51
+ overrides: { strategy: "context-full", remoteEnabled: false },
52
+ },
53
+ {
54
+ name: "remote",
55
+ description: "Summarize via the remote endpoint / provider-native compaction",
56
+ overrides: { strategy: "context-full", remoteEnabled: true },
57
+ requiresRemote: true,
58
+ },
59
+ {
60
+ name: "snapcompact",
61
+ description: "Archive history onto dense bitmap images the model reads back (no LLM call)",
62
+ overrides: { strategy: "snapcompact" },
63
+ rejectsFocus: true,
64
+ },
65
+ ];
66
+
67
+ /** Resolve a subcommand token (case-insensitive) to its mode definition. */
68
+ export function findCompactMode(name: string): CompactModeDef | undefined {
69
+ const key = name.trim().toLowerCase();
70
+ return COMPACT_MODES.find(mode => mode.name === key);
71
+ }
72
+
73
+ /** Parsed `/compact` arguments: an optional mode plus optional focus text. */
74
+ export interface ParsedCompactArgs {
75
+ mode?: CompactMode;
76
+ instructions?: string;
77
+ }
78
+
79
+ /**
80
+ * Split `/compact` args into a leading mode subcommand + focus instructions.
81
+ *
82
+ * Backward compatible: when the first token is not a known mode, the entire
83
+ * argument string is treated as focus instructions (the historical behavior).
84
+ * A recognized mode with `rejectsFocus` and trailing text is an error.
85
+ */
86
+ export function parseCompactArgs(args: string): ParsedCompactArgs | { error: string } {
87
+ const trimmed = args.trim();
88
+ if (!trimmed) return {};
89
+
90
+ const spaceIndex = trimmed.search(/\s/);
91
+ const firstToken = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
92
+ const mode = findCompactMode(firstToken);
93
+ if (!mode) {
94
+ // No recognized mode prefix — keep the whole thing as focus instructions.
95
+ return { instructions: trimmed };
96
+ }
97
+
98
+ const focus = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim();
99
+ if (mode.rejectsFocus && focus) {
100
+ return {
101
+ error: `/compact ${mode.name} does not take focus instructions (it archives history without an LLM summary).`,
102
+ };
103
+ }
104
+ return { mode: mode.name, instructions: focus || undefined };
105
+ }
@@ -7,9 +7,9 @@
7
7
  * args), `### Tool Result: <name>`, and the execution/summary sections.
8
8
  */
9
9
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
10
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
11
10
  import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
12
11
  import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
12
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
13
13
  import { YAML } from "bun";
14
14
  import { canonicalizeMessage } from "../utils/thinking-display";
15
15
  import {
@@ -7,8 +7,8 @@
7
7
  * as one-liners. No system prompt, no tool catalog, no config sections.
8
8
  */
9
9
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
10
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
11
10
  import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
11
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
12
12
  import type {
13
13
  BashExecutionMessage,
14
14
  BranchSummaryMessage,
@@ -495,8 +495,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
495
495
  * Returned without surrounding newlines so callers can position it freely.
496
496
  */
497
497
  export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
498
- const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`;
499
- return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`;
498
+ // A 0/1-line elision (e.g. one giant single line) would read as
499
+ // "[…0ln elided…]"; fall back to a byte count there.
500
+ if (elidedLines <= 1) return `[…${elidedBytes}B elided…]`;
501
+ return `[…${elidedLines}ln elided…]`;
500
502
  }
501
503
 
502
504
  /**
@@ -578,8 +580,6 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
578
580
  export interface InlineByteCapOptions {
579
581
  /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */
580
582
  maxBytes?: number;
581
- /** What the text is, for the elision marker (e.g. "bash output"). */
582
- label: string;
583
583
  /**
584
584
  * Persist the full text as a session artifact. When an artifact id is
585
585
  * returned, a `[raw output: artifact://<id>]` footer is appended so the
@@ -619,7 +619,7 @@ export async function enforceInlineByteCap(text: string, options: InlineByteCapO
619
619
  const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text);
620
620
  const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text);
621
621
  const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8"));
622
- const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`;
622
+ const marker = `[…${elidedBytes}B elided…]`;
623
623
  let composed = `${head}\n${marker}\n${tail}`;
624
624
 
625
625
  const artifactId = await options.saveArtifact?.(text);