@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.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 (180) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-be1f2t8h.md} +92 -0
  3. package/dist/cli.js +9986 -9776
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/settings-schema.d.ts +63 -8
  13. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  14. package/dist/types/cursor.d.ts +193 -8
  15. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  16. package/dist/types/edit/hashline/diff.d.ts +9 -1
  17. package/dist/types/edit/index.d.ts +8 -1
  18. package/dist/types/edit/streaming.d.ts +8 -1
  19. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  20. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  21. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  22. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  23. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  24. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  25. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  26. package/dist/types/lsp/client.d.ts +2 -0
  27. package/dist/types/main.d.ts +2 -0
  28. package/dist/types/mcp/manager.d.ts +33 -4
  29. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  30. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  31. package/dist/types/modes/components/session-selector.d.ts +6 -0
  32. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  33. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  34. package/dist/types/modes/components/usage-row.d.ts +2 -0
  35. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  36. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  37. package/dist/types/modes/interactive-mode.d.ts +4 -2
  38. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  39. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  40. package/dist/types/modes/theme/theme.d.ts +12 -1
  41. package/dist/types/modes/types.d.ts +2 -1
  42. package/dist/types/sdk.d.ts +1 -0
  43. package/dist/types/session/agent-session-events.d.ts +2 -0
  44. package/dist/types/session/agent-session-types.d.ts +36 -1
  45. package/dist/types/session/agent-session.d.ts +4 -2
  46. package/dist/types/session/claude-session-store.d.ts +13 -0
  47. package/dist/types/session/codex-session-store.d.ts +14 -0
  48. package/dist/types/session/credential-pin.d.ts +58 -0
  49. package/dist/types/session/foreign-session-import.d.ts +15 -0
  50. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  51. package/dist/types/session/foreign-session-store.d.ts +23 -0
  52. package/dist/types/session/session-advisors.d.ts +47 -2
  53. package/dist/types/session/session-entries.d.ts +19 -1
  54. package/dist/types/session/session-handoff.d.ts +4 -1
  55. package/dist/types/session/session-loader.d.ts +2 -0
  56. package/dist/types/session/session-maintenance.d.ts +1 -1
  57. package/dist/types/session/session-manager.d.ts +21 -0
  58. package/dist/types/session/session-tools.d.ts +25 -1
  59. package/dist/types/session/turn-recovery.d.ts +10 -4
  60. package/dist/types/system-prompt.d.ts +2 -1
  61. package/dist/types/task/executor.d.ts +11 -2
  62. package/dist/types/task/render.d.ts +7 -0
  63. package/dist/types/thinking.d.ts +18 -6
  64. package/dist/types/tools/acp-bridge.d.ts +28 -5
  65. package/dist/types/tools/grep.d.ts +17 -1
  66. package/dist/types/tools/hub/launch.d.ts +5 -1
  67. package/dist/types/tools/index.d.ts +4 -1
  68. package/dist/types/tools/path-utils.d.ts +19 -0
  69. package/dist/types/tools/read.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +2 -1
  71. package/dist/types/utils/changelog.d.ts +19 -0
  72. package/dist/types/utils/git.d.ts +10 -0
  73. package/dist/types/utils/jj.d.ts +11 -4
  74. package/dist/types/web/search/types.d.ts +2 -2
  75. package/package.json +12 -12
  76. package/src/advisor/runtime.ts +138 -12
  77. package/src/advisor/transcript-recorder.ts +56 -0
  78. package/src/auto-thinking/classifier.ts +48 -10
  79. package/src/cli/args.ts +6 -0
  80. package/src/cli/flag-tables.ts +2 -0
  81. package/src/cli/gallery-fixtures/fs.ts +25 -0
  82. package/src/cli/session-picker.ts +34 -17
  83. package/src/cli/ttsr-cli.ts +19 -1
  84. package/src/cli.ts +9 -0
  85. package/src/collab/guest.ts +37 -4
  86. package/src/collab/host.ts +1 -0
  87. package/src/commands/launch.ts +6 -0
  88. package/src/config/model-registry.ts +103 -25
  89. package/src/config/settings-schema.ts +65 -6
  90. package/src/config/settings.ts +25 -0
  91. package/src/cursor-bridge-tools.ts +81 -0
  92. package/src/cursor.ts +463 -12
  93. package/src/edit/edit-clipboard.ts +23 -0
  94. package/src/edit/hashline/diff.ts +49 -10
  95. package/src/edit/hashline/execute.ts +38 -6
  96. package/src/edit/hashline/filesystem.ts +27 -3
  97. package/src/edit/index.ts +12 -2
  98. package/src/edit/renderer.ts +4 -4
  99. package/src/edit/streaming.ts +17 -2
  100. package/src/export/html/template.js +1 -1
  101. package/src/extensibility/extensions/runner.ts +70 -0
  102. package/src/extensibility/extensions/types.ts +30 -0
  103. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  104. package/src/internal-urls/local-protocol.ts +3 -2
  105. package/src/internal-urls/mcp-protocol.ts +4 -1
  106. package/src/launch/terminal-output-worker-client.ts +53 -0
  107. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  108. package/src/launch/terminal-output-worker.ts +23 -0
  109. package/src/lsp/client.ts +18 -3
  110. package/src/lsp/index.ts +4 -0
  111. package/src/lsp/render.ts +1 -1
  112. package/src/main.ts +127 -56
  113. package/src/mcp/manager.ts +120 -18
  114. package/src/modes/acp/acp-agent.ts +15 -9
  115. package/src/modes/components/chat-transcript-builder.ts +30 -11
  116. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  117. package/src/modes/components/read-tool-group.ts +159 -17
  118. package/src/modes/components/session-selector.ts +19 -3
  119. package/src/modes/components/status-line/component.ts +381 -54
  120. package/src/modes/components/tool-execution.ts +78 -31
  121. package/src/modes/components/usage-row.ts +10 -5
  122. package/src/modes/controllers/event-controller.ts +26 -4
  123. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  124. package/src/modes/controllers/selector-controller.ts +94 -36
  125. package/src/modes/controllers/tan-command-controller.ts +14 -0
  126. package/src/modes/interactive-mode.ts +31 -17
  127. package/src/modes/rpc/rpc-client.ts +18 -0
  128. package/src/modes/rpc/rpc-mode.ts +16 -1
  129. package/src/modes/rpc/rpc-types.ts +11 -0
  130. package/src/modes/theme/theme.ts +151 -6
  131. package/src/modes/types.ts +2 -1
  132. package/src/modes/utils/ui-helpers.ts +38 -21
  133. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  134. package/src/prompts/system/plan-mode-active.md +2 -2
  135. package/src/prompts/system/system-prompt.md +3 -0
  136. package/src/prompts/system/xdev-mount-notice.md +1 -1
  137. package/src/prompts/tools/bash.md +15 -17
  138. package/src/prompts/tools/checkpoint.md +1 -1
  139. package/src/prompts/tools/glob.md +7 -6
  140. package/src/prompts/tools/grep.md +7 -6
  141. package/src/sdk.ts +178 -15
  142. package/src/session/agent-session-events.ts +1 -0
  143. package/src/session/agent-session-types.ts +40 -1
  144. package/src/session/agent-session.ts +302 -175
  145. package/src/session/claude-session-store.ts +426 -0
  146. package/src/session/codex-session-store.ts +673 -0
  147. package/src/session/credential-pin.ts +93 -0
  148. package/src/session/foreign-session-import.ts +52 -0
  149. package/src/session/foreign-session-jsonl.ts +29 -0
  150. package/src/session/foreign-session-store.ts +26 -0
  151. package/src/session/model-controls.ts +11 -3
  152. package/src/session/session-advisors.ts +140 -16
  153. package/src/session/session-entries.ts +21 -1
  154. package/src/session/session-handoff.ts +20 -3
  155. package/src/session/session-loader.ts +25 -9
  156. package/src/session/session-maintenance.ts +59 -11
  157. package/src/session/session-manager.ts +61 -0
  158. package/src/session/session-tools.ts +107 -5
  159. package/src/session/settings-stream-fn.ts +1 -0
  160. package/src/session/turn-recovery.ts +97 -51
  161. package/src/slash-commands/builtin-registry.ts +131 -3
  162. package/src/system-prompt.ts +3 -2
  163. package/src/task/executor.ts +17 -4
  164. package/src/task/render.ts +20 -4
  165. package/src/thinking.ts +29 -10
  166. package/src/tools/acp-bridge.ts +52 -8
  167. package/src/tools/browser.ts +5 -0
  168. package/src/tools/checkpoint.ts +0 -13
  169. package/src/tools/grep.ts +64 -8
  170. package/src/tools/hub/launch.ts +10 -15
  171. package/src/tools/index.ts +28 -4
  172. package/src/tools/path-utils.ts +88 -0
  173. package/src/tools/read.ts +3 -0
  174. package/src/tools/write.ts +9 -3
  175. package/src/tools/xdev.ts +39 -14
  176. package/src/utils/changelog.ts +144 -4
  177. package/src/utils/git.ts +61 -14
  178. package/src/utils/jj.ts +19 -13
  179. package/src/web/search/providers/codex.ts +34 -0
  180. package/src/web/search/types.ts +1 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session-file persistence of the OAuth account that served a session.
3
+ *
4
+ * Provider prompt caches are account-scoped (Anthropic bills a full cache
5
+ * re-write after an account flip), and the auth store's session-sticky routing
6
+ * is process-local when a remote auth broker is configured — the broker
7
+ * store's KV cache is in-memory, so sticky rows die with the CLI process.
8
+ * Resuming a session in a fresh process then re-ranks accounts by usage
9
+ * headroom, which is biased *away* from the account that just served the
10
+ * session (it has the highest recent burn), cold-missing the entire prefix.
11
+ *
12
+ * These helpers close the loop through the session file itself: after each
13
+ * assistant turn the serving account is recorded as a `credential_pin` entry,
14
+ * and on session adoption the pin is matched against the stored accounts and
15
+ * seeded back into the auth store with the session's effective last-use
16
+ * timestamp, so the provider's warm-window semantics still decide whether to
17
+ * stick or re-rank.
18
+ */
19
+
20
+ import type { AuthStorage } from "./auth-storage";
21
+ import type { SessionManager } from "./session-manager";
22
+
23
+ /** Account fields shared by `OAuthAccountIdentity` and `OAuthAccountSummary`. */
24
+ interface CredentialPinIdentity {
25
+ accountId?: string;
26
+ email?: string;
27
+ projectId?: string;
28
+ orgId?: string;
29
+ }
30
+
31
+ /**
32
+ * Stable identifier for a provider account within its billing scope. The
33
+ * digest covers the full scope tuple — the same account in two orgs (Anthropic
34
+ * multi-subscription) or projects (Gemini) is two distinct cache domains and
35
+ * must produce two distinct pins. The digest input is the persisted contract
36
+ * for `CredentialPinEntry.hash` — changing it orphans every recorded pin.
37
+ *
38
+ * Hashing avoids embedding raw emails/uuids in session files, but an unsalted
39
+ * digest of a guessable email is still linkable — treat exported sessions
40
+ * accordingly.
41
+ *
42
+ * Returns `undefined` when the identity carries no account key at all.
43
+ */
44
+ export function credentialPinHash(provider: string, identity: CredentialPinIdentity): string | undefined {
45
+ if (!identity.accountId && !identity.email) return undefined;
46
+ const key = [
47
+ provider,
48
+ identity.accountId ?? "",
49
+ identity.email ?? "",
50
+ identity.orgId ?? "",
51
+ identity.projectId ?? "",
52
+ ].join("\0");
53
+ return new Bun.CryptoHasher("sha256").update(key).digest("hex");
54
+ }
55
+
56
+ /**
57
+ * Record the account that served the latest assistant turn for `provider`.
58
+ * Appends a `credential_pin` entry only when the account differs from the
59
+ * branch's latest pin, so steady-state sessions add a single entry; the
60
+ * effective last-use time is derived from later assistant turns on read
61
+ * (see `SessionManager.getCredentialPins`).
62
+ */
63
+ export function recordCredentialPin(
64
+ authStorage: AuthStorage,
65
+ sessionManager: SessionManager,
66
+ sessionId: string,
67
+ provider: string,
68
+ ): void {
69
+ const identity = authStorage.getOAuthAccountIdentity(provider, sessionId);
70
+ if (!identity) return;
71
+ const hash = credentialPinHash(provider, identity);
72
+ if (!hash || sessionManager.getCredentialPins().get(provider)?.hash === hash) return;
73
+ sessionManager.appendCredentialPin(provider, hash);
74
+ }
75
+
76
+ /**
77
+ * Re-pin the accounts recorded in the session file onto the auth store's
78
+ * session stickiness. No-op per provider when the account is gone (logged out)
79
+ * or when a live sticky already exists (same-process branch/session switches
80
+ * must not clobber fresher routing). Seeds with the session's effective
81
+ * last-use time so stale resumes still fall through to usage ranking.
82
+ */
83
+ export function seedCredentialPins(authStorage: AuthStorage, sessionManager: SessionManager, sessionId: string): void {
84
+ for (const [provider, pin] of sessionManager.getCredentialPins()) {
85
+ const accounts = authStorage.listOAuthAccounts(provider, sessionId);
86
+ if (accounts.length === 0 || accounts.some(account => account.active)) continue;
87
+ const match = accounts.find(account => credentialPinHash(provider, account) === pin.hash);
88
+ if (!match) continue;
89
+ authStorage.pinSessionOAuthAccount(provider, sessionId, match.credentialId, {
90
+ lastUsedAtMs: pin.lastUsedAt,
91
+ });
92
+ }
93
+ }
@@ -0,0 +1,52 @@
1
+ import { directoryExists } from "@oh-my-pi/pi-utils";
2
+ import { ClaudeSessionStore } from "./claude-session-store";
3
+ import { CodexSessionStore } from "./codex-session-store";
4
+ import type { ForeignSessionInfo, ForeignSessionSource, ForeignSessionStore } from "./foreign-session-store";
5
+ import type { SessionInfo } from "./session-listing";
6
+ import type { SessionManager } from "./session-manager";
7
+
8
+ /** Construct the importer for a supported foreign session source. */
9
+ export function createForeignSessionStore(source: ForeignSessionSource): ForeignSessionStore {
10
+ return source === "claude" ? new ClaudeSessionStore() : new CodexSessionStore();
11
+ }
12
+
13
+ /** Display name for a supported foreign session source. */
14
+ export function foreignSessionSourceName(source: ForeignSessionSource): string {
15
+ return source === "claude" ? "Claude" : "Codex";
16
+ }
17
+
18
+ /** Convert lightweight foreign metadata for the existing session picker. */
19
+ export function foreignSessionInfoToSessionInfo(info: ForeignSessionInfo): SessionInfo {
20
+ const firstMessage = info.firstMessage ?? "(no messages)";
21
+ return {
22
+ path: info.path,
23
+ id: info.id,
24
+ cwd: info.cwd,
25
+ title: info.title,
26
+ created: info.created,
27
+ modified: info.modified,
28
+ messageCount: info.messageCount ?? 0,
29
+ size: 0,
30
+ firstMessage,
31
+ allMessagesText: firstMessage,
32
+ };
33
+ }
34
+
35
+ /** Import and persist one foreign session under a fresh OMP session identity. */
36
+ export async function persistForeignSession(
37
+ store: ForeignSessionStore,
38
+ info: ForeignSessionInfo,
39
+ options?: { fallbackCwd?: string; sessionDir?: string; suppressBreadcrumb?: boolean },
40
+ ): Promise<SessionManager> {
41
+ const imported = await store.load(info);
42
+ imported.appendCustomEntry("foreign_session_import", {
43
+ source: info.source,
44
+ sourceId: info.id,
45
+ sourcePath: info.path,
46
+ sourceCwd: info.cwd,
47
+ });
48
+ if (options?.fallbackCwd && !(await directoryExists(imported.getCwd()))) {
49
+ await imported.moveTo(options.fallbackCwd);
50
+ }
51
+ return await imported.persistCopy(options);
52
+ }
@@ -0,0 +1,29 @@
1
+ import { isRecord, readLines } from "@oh-my-pi/pi-utils";
2
+
3
+ /** One readable object record from a foreign JSONL transcript. */
4
+ export interface ForeignJsonRecord {
5
+ readonly value: Record<string, unknown>;
6
+ readonly line: number;
7
+ }
8
+
9
+ /** Stream valid object records while tolerating malformed or truncated lines. */
10
+ export async function* readForeignJsonRecords(filePath: string): AsyncGenerator<ForeignJsonRecord> {
11
+ const decoder = new TextDecoder();
12
+ let line = 0;
13
+ for await (const bytes of readLines(Bun.file(filePath).stream())) {
14
+ line += 1;
15
+ try {
16
+ const value: unknown = JSON.parse(decoder.decode(bytes));
17
+ if (isRecord(value)) yield { value, line };
18
+ } catch {
19
+ // A partially-written line must not hide the readable transcript prefix.
20
+ }
21
+ }
22
+ }
23
+
24
+ /** Read every valid object record from a foreign JSONL transcript. */
25
+ export async function collectForeignJsonRecords(filePath: string): Promise<ForeignJsonRecord[]> {
26
+ const records: ForeignJsonRecord[] = [];
27
+ for await (const record of readForeignJsonRecords(filePath)) records.push(record);
28
+ return records;
29
+ }
@@ -0,0 +1,26 @@
1
+ import type { SessionManager } from "./session-manager";
2
+
3
+ /** External coding-agent session source supported by OMP imports. */
4
+ export type ForeignSessionSource = "claude" | "codex";
5
+
6
+ /** Lightweight source metadata used to choose a foreign session before loading its transcript. */
7
+ export interface ForeignSessionInfo {
8
+ readonly source: ForeignSessionSource;
9
+ readonly id: string;
10
+ readonly path: string;
11
+ readonly cwd: string;
12
+ readonly title?: string;
13
+ readonly created: Date;
14
+ readonly modified: Date;
15
+ readonly messageCount?: number;
16
+ readonly firstMessage?: string;
17
+ }
18
+
19
+ /** Lists and converts sessions owned by another coding agent. */
20
+ export interface ForeignSessionStore {
21
+ readonly source: ForeignSessionSource;
22
+ /** Lists source sessions without parsing complete transcripts. */
23
+ list(): Promise<ForeignSessionInfo[]>;
24
+ /** Converts one source session into a non-persistent OMP session. */
25
+ load(session: ForeignSessionInfo): Promise<SessionManager>;
26
+ }
@@ -3,6 +3,7 @@ import type { Model, ProviderSessionState, ServiceTier, ServiceTierByFamily, Ser
3
3
  import {
4
4
  clearAnthropicFastModeFallback,
5
5
  Effort,
6
+ isAnthropicFastModeFallbackDisabled,
6
7
  realizesPriorityServiceTier,
7
8
  resolveModelServiceTier,
8
9
  serviceTierFamily,
@@ -596,8 +597,8 @@ export class ModelControls {
596
597
  let resolved: Effort | undefined;
597
598
  if (this.#host.magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
598
599
  // The user explicitly asked for maximum thinking; bypass the classifier
599
- // (and its xhigh auto ceiling) and jump straight to the highest
600
- // supported level for this model.
600
+ // (and the `providers.autoThinkingMaxEffort` ceiling) and jump straight
601
+ // to the highest supported level for this model.
601
602
  resolved = clampAutoThinkingEffort(model, Effort.Max);
602
603
  } else {
603
604
  const controller = new AbortController();
@@ -666,7 +667,11 @@ export class ModelControls {
666
667
  */
667
668
  isFastModeActive(): boolean {
668
669
  const model = this.#model;
669
- return !!model && realizesPriorityServiceTier(this.effectiveServiceTier(model), model);
670
+ if (!model || !realizesPriorityServiceTier(this.effectiveServiceTier(model), model)) return false;
671
+ if (model.provider === "anthropic") {
672
+ return !isAnthropicFastModeFallbackDisabled(this.#host.providerSessionState, model);
673
+ }
674
+ return true;
670
675
  }
671
676
 
672
677
  /**
@@ -731,6 +736,9 @@ export class ModelControls {
731
736
  if (this.#serviceTierByFamily[family] === "priority") this.setServiceTierFamily(family, undefined);
732
737
  return true;
733
738
  }
739
+ if (family === "anthropic" && this.#serviceTierByFamily.anthropic === "priority") {
740
+ clearAnthropicFastModeFallback(this.#host.providerSessionState);
741
+ }
734
742
  this.setServiceTierFamily(family, "priority");
735
743
  return true;
736
744
  }
@@ -2,6 +2,7 @@ import {
2
2
  Agent,
3
3
  type AgentMessage,
4
4
  type AgentTool,
5
+ type AgentToolContext,
5
6
  AppendOnlyContextManager,
6
7
  type CompactionSummaryMessage,
7
8
  countTokens,
@@ -16,9 +17,11 @@ import {
16
17
  compactionContextTokens,
17
18
  createCompactionSummaryMessage,
18
19
  estimateTokens,
20
+ NativeCompactionError,
19
21
  prepareCompaction,
20
22
  type SessionMessageEntry,
21
23
  shouldCompact,
24
+ shouldUseProviderNativeCompaction,
22
25
  } from "@oh-my-pi/pi-agent-core/compaction";
23
26
  import type {
24
27
  AssistantMessage,
@@ -67,7 +70,8 @@ import {
67
70
  import { MODEL_ROLES } from "../config/model-roles";
68
71
  import { serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
69
72
  import type { Settings } from "../config/settings";
70
- import { CursorExecHandlers } from "../cursor";
73
+ import { CursorExecHandlers, type CursorMcpResourceAdapter } from "../cursor";
74
+ import { bridgeToolMap } from "../cursor-bridge-tools";
71
75
  import { estimateToolSchemaTokens } from "../modes/utils/context-usage";
72
76
  import type { PlanModeState } from "../plan-mode/state";
73
77
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
@@ -177,12 +181,44 @@ interface AdvisorRuntimeDescriptor {
177
181
  export interface SessionAdvisorsOptions {
178
182
  enabled: boolean;
179
183
  tools?: AgentTool[];
184
+ /**
185
+ * Build a `grep` honoring a Cursor `pi_grep` frame's own context width and
186
+ * match cap. The advisor's tools are fixed instances carrying session
187
+ * defaults, so without this an advisor running against Cursor silently
188
+ * drops both fields — the same gap the primary bridge closes.
189
+ */
190
+ createGrepTool?(options: { context?: number; totalMatchLimit?: number }): AgentTool | undefined;
191
+ /**
192
+ * Build the `replace`-mode `edit` a Cursor `pi_edit` frame needs. The
193
+ * advisor's own instance follows the configured `edit.mode` (`hashline` by
194
+ * default), whose schema the frame's `old_text`/`new_text` pairs do not
195
+ * match, so without this every native advisor edit fails validation.
196
+ */
197
+ createEditTool?(): AgentTool | undefined;
198
+ /**
199
+ * The execute-time context the bridge's tools resolve approval from.
200
+ *
201
+ * `ExtensionToolWrapper` reads the approval mode, per-tool policies and
202
+ * `autoApprove` only from here; with none it falls back to `yolo` and empty
203
+ * policies, so a native frame would run past a configured `ask` or `deny`.
204
+ */
205
+ getToolContext?: () => AgentToolContext | undefined;
206
+ /**
207
+ * The live MCP connections Cursor's resource frames answer from.
208
+ *
209
+ * Advisors share the session's connections and may hold tools from those
210
+ * same servers, so without this their frames report that every server
211
+ * advertises nothing.
212
+ */
213
+ mcpResources?: CursorMcpResourceAdapter;
180
214
  watchdogPrompt?: string;
181
215
  sharedInstructions?: string;
182
216
  contextPrompt?: string;
183
217
  configs?: AdvisorConfig[];
184
218
  streamFn?: StreamFn;
185
219
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
220
+ /** Advisor spend already persisted for this session, restored on resume. */
221
+ initialCosts?: ReadonlyMap<string, number>;
186
222
  }
187
223
 
188
224
  /** Options accepted when an advisor injects a primary-session message. */
@@ -221,7 +257,11 @@ export interface SessionAdvisorsHost {
221
257
  hasPendingNextTurnMessages(): boolean;
222
258
  convertToLlmForSideRequest(messages: AgentMessage[]): Message[];
223
259
  effectiveServiceTier(model: Model): ServiceTier | undefined;
224
- resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined>;
260
+ resolveContextPromotionTarget(
261
+ currentModel: Model,
262
+ contextWindow: number,
263
+ signal: AbortSignal,
264
+ ): Promise<Model | undefined>;
225
265
  resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[];
226
266
  resolveRetryFallbackRole(currentSelector: string, currentModel?: Model | null): string | undefined;
227
267
  findRetryFallbackCandidates(
@@ -244,6 +284,10 @@ export class SessionAdvisors {
244
284
  readonly #host: SessionAdvisorsHost;
245
285
  #advisorEnabled: boolean;
246
286
  #advisorTools: AgentTool[] | undefined;
287
+ #advisorCreateGrepTool: SessionAdvisorsOptions["createGrepTool"];
288
+ #advisorCreateEditTool: SessionAdvisorsOptions["createEditTool"];
289
+ #advisorGetToolContext: SessionAdvisorsOptions["getToolContext"];
290
+ #advisorMcpResources: SessionAdvisorsOptions["mcpResources"];
247
291
  #advisorWatchdogPrompt: string | undefined;
248
292
  #advisorSharedInstructions: string | undefined;
249
293
  #advisorContextPrompt: string | undefined;
@@ -266,12 +310,17 @@ export class SessionAdvisors {
266
310
  this.#host = host;
267
311
  this.#advisorEnabled = options.enabled;
268
312
  this.#advisorTools = options.tools;
313
+ this.#advisorCreateGrepTool = options.createGrepTool;
314
+ this.#advisorCreateEditTool = options.createEditTool;
315
+ this.#advisorGetToolContext = options.getToolContext;
316
+ this.#advisorMcpResources = options.mcpResources;
269
317
  this.#advisorWatchdogPrompt = options.watchdogPrompt;
270
318
  this.#advisorSharedInstructions = options.sharedInstructions;
271
319
  this.#advisorContextPrompt = options.contextPrompt;
272
320
  this.#advisorConfigs = options.configs;
273
321
  this.#advisorStreamFn = options.streamFn;
274
322
  this.#transformProviderContext = options.transformProviderContext;
323
+ if (options.initialCosts) this.#advisorCosts = new Map(options.initialCosts);
275
324
  if (this.#advisorEnabled) this.#buildAdvisorRuntime();
276
325
  }
277
326
 
@@ -313,6 +362,15 @@ export class SessionAdvisors {
313
362
  this.#stopAdvisorRuntime();
314
363
  }
315
364
 
365
+ /**
366
+ * Pause advisor work while old-session recorder feeds remain attached, then
367
+ * detach only after any active prompt has settled.
368
+ */
369
+ async drainAndDetachRecorders(): Promise<void> {
370
+ await Promise.all(this.#advisors.map(advisor => advisor.runtime.pauseForSessionTransition()));
371
+ await this.detachAndCloseRecorders();
372
+ }
373
+
316
374
  /** Detaches and drains recorder feeds before transcript artifacts are removed. */
317
375
  async detachAndCloseRecorders(): Promise<void> {
318
376
  const closes: Promise<void>[] = [];
@@ -325,6 +383,14 @@ export class SessionAdvisors {
325
383
  await Promise.all(closes);
326
384
  }
327
385
 
386
+ /** Reattach recorder feeds and resume work after a rolled-back or preserving transition. */
387
+ reattachRecorderFeeds(): void {
388
+ for (const advisor of this.#advisors) {
389
+ if (!advisor.agentUnsubscribe) this.#attachAdvisorRecorderFeed(advisor);
390
+ advisor.runtime.resumeAfterSessionTransition();
391
+ }
392
+ }
393
+
328
394
  /** Re-primes advisor transcript views across a conversation boundary. */
329
395
  resetSessionState(options: { preserveCost?: boolean } = {}): void {
330
396
  this.#resetAdvisorSessionState(options.preserveCost === true);
@@ -335,6 +401,11 @@ export class SessionAdvisors {
335
401
  this.#advisorCosts.clear();
336
402
  }
337
403
 
404
+ /** Replace the ledger with the spend recorded for the session becoming active. */
405
+ restoreCost(costs: ReadonlyMap<string, number>): void {
406
+ this.#advisorCosts = new Map(costs);
407
+ }
408
+
338
409
  /**
339
410
  * Rebind every live advisor to the active primary conversation's provider
340
411
  * identity (session id, prompt-cache key, credential + metadata resolvers,
@@ -689,11 +760,29 @@ export class SessionAdvisors {
689
760
  // to delete workspace files it was never granted (issue #5680 review).
690
761
  const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit");
691
762
  if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete");
763
+ // `pi_edit` speaks `replace`'s `old_text`/`new_text` schema, which the
764
+ // advisor's ordinary `EditTool` (built at the session's configured
765
+ // `edit.mode`, `hashline` by default) does not accept. The bridge map
766
+ // swaps in a `replace` instance for the exec channel only — the
767
+ // advisor's own loop keeps the tool it was given — and only when
768
+ // `edit` was actually granted.
692
769
  const advisorCursorExecHandlers = new CursorExecHandlers({
693
770
  cwd: this.#host.sessionManager.getCwd(),
694
771
  getCwd: () => this.#host.sessionManager.getCwd(),
695
- tools: advisorToolMap,
696
- allowNativeDelete: advisorCanMutateFiles,
772
+ tools: bridgeToolMap(advisorToolMap, this.#advisorCreateEditTool),
773
+ // Approval mode, per-tool policies and `autoApprove` live only on
774
+ // this context; without it every bridge tool resolves as `yolo`.
775
+ getToolContext: this.#advisorGetToolContext,
776
+ allowDirectFileMutation: advisorCanMutateFiles,
777
+ // Gated on the advisor's own grant: the factory builds a fresh
778
+ // tool, so handing it over unconditionally would give a roster
779
+ // without `grep` a search tool it was denied.
780
+ createGrepTool: advisorToolMap.has("grep") ? this.#advisorCreateGrepTool : undefined,
781
+ // Advisors share the session's live MCP connections, so their
782
+ // resource frames answer from the same catalog the primary sees.
783
+ // Not gated on a tool grant: reading what a server advertises is
784
+ // not the same permission as calling one of its tools.
785
+ mcpResources: this.#advisorMcpResources,
697
786
  });
698
787
  const baseAdvisorStreamFn = this.#advisorStreamFn ?? streamSimple;
699
788
  const advisorStreamFn: StreamFn = (requestModel, context, options) =>
@@ -784,10 +873,13 @@ export class SessionAdvisors {
784
873
  const runtime = new AdvisorRuntime(advisorAgentFacade, {
785
874
  snapshotMessages: () => this.#host.agent.state.messages,
786
875
  enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity),
787
- maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
876
+ maintainContext: (incomingTokens, signal) =>
877
+ this.#maintainAdvisorContext(advisorRef, incomingTokens, signal),
788
878
  obfuscator: this.#host.obfuscator,
879
+ getModelIdentity: () => formatModelString(advisorRef.agent.state.model),
789
880
  beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
790
- onTurnError: (error, failedMessages) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages),
881
+ onTurnError: (error, failedMessages, signal) =>
882
+ this.#recoverAdvisorTurn(advisorRef, error, failedMessages, signal),
791
883
  onTurnSuccess: async () => {
792
884
  const fallback = advisorRef.retryFallback;
793
885
  if (!advisorRef.retryFallbackPendingSuccess || !fallback) return;
@@ -1015,7 +1107,7 @@ export class SessionAdvisors {
1015
1107
  }
1016
1108
 
1017
1109
  /** Restore an advisor's configured primary once its fallback cooldown expires. */
1018
- async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor): Promise<void> {
1110
+ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor, signal: AbortSignal): Promise<void> {
1019
1111
  const fallback = advisor.retryFallback;
1020
1112
  if (!fallback || getRetryFallbackRevertPolicy(this.#host.settings) !== "cooldown-expiry") return;
1021
1113
 
@@ -1043,8 +1135,9 @@ export class SessionAdvisors {
1043
1135
  const primaryModel =
1044
1136
  resolvedPrimary.model ?? this.#host.modelRegistry.find(originalSelector.provider, originalSelector.id);
1045
1137
  if (!primaryModel) return;
1046
- const apiKey = await this.#host.modelRegistry.getApiKey(primaryModel, advisor.providerSessionId);
1138
+ const apiKey = await this.#host.modelRegistry.getApiKey(primaryModel, advisor.providerSessionId, { signal });
1047
1139
  if (!apiKey) return;
1140
+ signal.throwIfAborted();
1048
1141
 
1049
1142
  const thinkingToApply =
1050
1143
  advisor.thinkingLevel === fallback.lastAppliedThinkingLevel
@@ -1064,6 +1157,7 @@ export class SessionAdvisors {
1064
1157
  advisor: ActiveAdvisor,
1065
1158
  error: unknown,
1066
1159
  failedMessages: readonly AgentMessage[],
1160
+ signal: AbortSignal,
1067
1161
  ): Promise<boolean> {
1068
1162
  if (error instanceof AdvisorOutputQuarantinedError) return false;
1069
1163
 
@@ -1086,6 +1180,7 @@ export class SessionAdvisors {
1086
1180
  retryAfterMs: extractRetryHint(undefined, message),
1087
1181
  baseUrl: currentModel.baseUrl,
1088
1182
  modelId: currentModel.id,
1183
+ signal,
1089
1184
  },
1090
1185
  );
1091
1186
  return outcome.switched;
@@ -1117,6 +1212,7 @@ export class SessionAdvisors {
1117
1212
  retryAfterMs,
1118
1213
  baseUrl: currentModel.baseUrl,
1119
1214
  modelId: currentModel.id,
1215
+ signal,
1120
1216
  },
1121
1217
  );
1122
1218
  if (outcome.switched) return true;
@@ -1134,8 +1230,9 @@ export class SessionAdvisors {
1134
1230
  const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings);
1135
1231
  const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id);
1136
1232
  if (!candidate || modelsAreEqual(candidate, currentModel)) continue;
1137
- const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisor.providerSessionId);
1233
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisor.providerSessionId, { signal });
1138
1234
  if (!apiKey) continue;
1235
+ signal.throwIfAborted();
1139
1236
 
1140
1237
  const originalThinkingLevel = advisor.thinkingLevel;
1141
1238
  const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel;
@@ -1163,13 +1260,18 @@ export class SessionAdvisors {
1163
1260
  return false;
1164
1261
  }
1165
1262
 
1166
- async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
1263
+ async #promoteAdvisorContextModel(
1264
+ advisor: ActiveAdvisor,
1265
+ currentModel: Model,
1266
+ signal: AbortSignal,
1267
+ ): Promise<boolean> {
1167
1268
  const promotionSettings = this.#host.settings.getGroup("contextPromotion");
1168
1269
  if (!promotionSettings.enabled) return false;
1169
1270
  const contextWindow = currentModel.contextWindow ?? 0;
1170
1271
  if (contextWindow <= 0) return false;
1171
- const targetModel = await this.#host.resolveContextPromotionTarget(currentModel, contextWindow);
1272
+ const targetModel = await this.#host.resolveContextPromotionTarget(currentModel, contextWindow, signal);
1172
1273
  if (!targetModel) return false;
1274
+ signal.throwIfAborted();
1173
1275
 
1174
1276
  // Preserve this advisor's own thinking level (a configured `model:...:high`
1175
1277
  // keeps its suffix across a promotion); only the model changes.
@@ -1193,8 +1295,12 @@ export class SessionAdvisors {
1193
1295
  }
1194
1296
  }
1195
1297
 
1196
- async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
1197
- await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor);
1298
+ async #maintainAdvisorContext(
1299
+ advisor: ActiveAdvisor,
1300
+ incomingTokens: number,
1301
+ signal: AbortSignal,
1302
+ ): Promise<boolean> {
1303
+ await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor, signal);
1198
1304
  const agent = advisor.agent;
1199
1305
 
1200
1306
  const compactionSettings = this.#host.settings.getGroup("compaction");
@@ -1229,7 +1335,7 @@ export class SessionAdvisors {
1229
1335
  }
1230
1336
 
1231
1337
  // 1. Try promotion first
1232
- if (await this.#promoteAdvisorContextModel(advisor, advisorModel)) {
1338
+ if (await this.#promoteAdvisorContextModel(advisor, advisorModel, signal)) {
1233
1339
  // Promotion succeeded, check if new model has enough space
1234
1340
  const newModel = agent.state.model;
1235
1341
  const newWindow = newModel.contextWindow ?? 0;
@@ -1296,6 +1402,7 @@ export class SessionAdvisors {
1296
1402
 
1297
1403
  let compactResult: CompactionResult | undefined;
1298
1404
  let lastError: unknown;
1405
+ let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined;
1299
1406
  // Instrument the advisor's overflow-compaction one-shot like the primary
1300
1407
  // compaction path so the advisor model's maintenance call also emits spans.
1301
1408
  const telemetry = resolveTelemetry(agent.telemetry, advisorProviderSessionId);
@@ -1307,8 +1414,16 @@ export class SessionAdvisors {
1307
1414
  });
1308
1415
 
1309
1416
  for (const candidate of candidates) {
1310
- const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId);
1417
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal });
1311
1418
  if (!apiKey) continue;
1419
+ if (
1420
+ nativeCompactionFailure &&
1421
+ (candidate.provider !== nativeCompactionFailure.provider ||
1422
+ !shouldUseProviderNativeCompaction(candidate, compactionSettings))
1423
+ ) {
1424
+ throw nativeCompactionFailure.error;
1425
+ }
1426
+
1312
1427
  // The advisor overflow-compaction one-shot bypasses the advisor `Agent`,
1313
1428
  // so its installed metadata resolver never runs. Emit the same
1314
1429
  // `metadata.user_id` identity here (resolved per candidate provider,
@@ -1324,7 +1439,7 @@ export class SessionAdvisors {
1324
1439
  candidate,
1325
1440
  this.#host.modelRegistry.resolver(candidate, advisorProviderSessionId),
1326
1441
  undefined,
1327
- undefined,
1442
+ signal,
1328
1443
  {
1329
1444
  thinkingLevel: advisorCompactionThinkingLevel,
1330
1445
  convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
@@ -1339,10 +1454,19 @@ export class SessionAdvisors {
1339
1454
  );
1340
1455
  break;
1341
1456
  } catch (error) {
1457
+ if (signal.aborted) throw error;
1458
+ const id = AIError.classify(error, candidate.api);
1459
+ if (error instanceof NativeCompactionError && !AIError.is(id, AIError.Flag.AuthFailed)) {
1460
+ nativeCompactionFailure ??= { error, provider: candidate.provider };
1461
+ lastError = nativeCompactionFailure.error;
1462
+ continue;
1463
+ }
1342
1464
  lastError = error;
1343
1465
  }
1344
1466
  }
1345
1467
 
1468
+ if (!compactResult && nativeCompactionFailure) throw nativeCompactionFailure.error;
1469
+
1346
1470
  if (!compactResult) {
1347
1471
  logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
1348
1472
  return true;
@@ -154,6 +154,7 @@ export interface TitleChangeEntry extends SessionEntryBase {
154
154
  declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
155
155
  interface CustomCompactionSessionEntries {
156
156
  titleChange: TitleChangeEntry;
157
+ credentialPin: CredentialPinEntry;
157
158
  }
158
159
  }
159
160
 
@@ -164,6 +165,24 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
164
165
  injectedRules: string[];
165
166
  }
166
167
 
168
+ /**
169
+ * Records which OAuth account served this session's requests for a provider.
170
+ *
171
+ * Provider prompt caches (Anthropic in particular) are account-scoped, and the
172
+ * auth store's session-sticky routing is process-local under a remote auth
173
+ * broker, so resume must re-pin the same account to reuse the warm cache
174
+ * prefix. Stores a sha-256 of the account + billing-scope tuple instead of
175
+ * the raw email/uuid/org; note an unsalted digest of a guessable email is
176
+ * still linkable, so exported sessions are pseudonymous, not anonymous.
177
+ */
178
+ export interface CredentialPinEntry extends SessionEntryBase {
179
+ type: "credential_pin";
180
+ /** Provider id the pin applies to (e.g. "anthropic"). */
181
+ provider: string;
182
+ /** `credentialPinHash()` of the serving account's identity + scope tuple. */
183
+ hash: string;
184
+ }
185
+
167
186
  /** Session init entry - captures initial context for subagent sessions (debugging/replay). */
168
187
  export interface SessionInitEntry extends SessionEntryBase {
169
188
  type: "session_init";
@@ -230,7 +249,8 @@ export type SessionEntry =
230
249
  | TitleChangeEntry
231
250
  | TtsrInjectionEntry
232
251
  | SessionInitEntry
233
- | ModeChangeEntry;
252
+ | ModeChangeEntry
253
+ | CredentialPinEntry;
234
254
 
235
255
  /** Raw logical file entry after loaders strip any fixed-width title slot. */
236
256
  export type FileEntry = SessionHeader | SessionEntry;