@oh-my-pi/pi-coding-agent 17.1.7 → 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 (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -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;
@@ -64,7 +64,10 @@ export interface SessionHandoffHost {
64
64
  clearPendingNextTurnMessages(): void;
65
65
  resetTodoCycle(): void;
66
66
  buildDisplaySessionContext(): SessionContext;
67
- resetAdvisorRuntimes(): void;
67
+ resetAdvisorSessionState(): void;
68
+ drainAndDetachAdvisorRecorders(): Promise<void>;
69
+ reattachAdvisorRecorderFeeds(): void;
70
+ clearAdvisorCost(): void;
68
71
  syncTodoPhasesFromBranch(): void;
69
72
  }
70
73
 
@@ -124,6 +127,8 @@ export class SessionHandoff {
124
127
  }
125
128
  }
126
129
 
130
+ let advisorRecordersDetached = false;
131
+ let sessionTransitioned = false;
127
132
  try {
128
133
  if (handoffSignal.aborted) {
129
134
  throw new Error("Handoff cancelled");
@@ -224,14 +229,21 @@ export class SessionHandoff {
224
229
  }
225
230
  await this.#host.flushPendingBash();
226
231
  await this.#host.sessionManager.flush();
232
+ advisorRecordersDetached = true;
233
+ // Stop and settle in-flight advisors while the old-session feeds can still
234
+ // observe message_end, then mute before opening the replacement session.
235
+ await this.#host.drainAndDetachAdvisorRecorders();
227
236
  const bashTransition = this.#host.beginBashSessionTransition();
228
237
  this.#host.cancelOwnAsyncJobs();
229
- let sessionTransitioned = false;
230
238
  try {
231
239
  await this.#host.sessionManager.newSession(
232
240
  previousSessionFile ? { parentSession: previousSessionFile } : undefined,
233
241
  );
234
242
  this.#host.markBashSessionTransition(bashTransition);
243
+ // The handoff opens a fresh conversation, so the spend of the one it
244
+ // summarizes stays with it. Clearing here, at the commit point, keeps the
245
+ // status line honest even if a later step throws.
246
+ this.#host.clearAdvisorCost();
235
247
  sessionTransitioned = true;
236
248
  } finally {
237
249
  this.#host.finishBashSessionTransition(bashTransition, sessionTransitioned);
@@ -284,7 +296,8 @@ export class SessionHandoff {
284
296
  // Rebuild agent messages from session
285
297
  const sessionContext = this.#host.buildDisplaySessionContext();
286
298
  this.#host.agent.replaceMessages(sessionContext.messages);
287
- this.#host.resetAdvisorRuntimes();
299
+ this.#host.resetAdvisorSessionState();
300
+ advisorRecordersDetached = false;
288
301
  this.#host.syncTodoPhasesFromBranch();
289
302
  if (this.#host.extensionRunner) {
290
303
  await this.#host.extensionRunner.emit({
@@ -301,6 +314,10 @@ export class SessionHandoff {
301
314
  }
302
315
  throw error;
303
316
  } finally {
317
+ if (advisorRecordersDetached) {
318
+ if (sessionTransitioned) this.#host.resetAdvisorSessionState();
319
+ else this.#host.reattachAdvisorRecorderFeeds();
320
+ }
304
321
  sourceSignal?.removeEventListener("abort", onSourceAbort);
305
322
  this.#handoffAbortController = undefined;
306
323
  }
@@ -218,7 +218,10 @@ function toolCallLine(
218
218
  return base;
219
219
  }
220
220
 
221
- /** One line for a user-initiated `!`/`$` execution. */
221
+ /** One line for a user-initiated `!`/`$` execution. Always attributed to the
222
+ * user: these roles never carry agent-run commands (the model's bash goes
223
+ * through `toolCall`), so the `user-` prefix makes provenance explicit for the
224
+ * advisor and history readers regardless of render mode. */
222
225
  function executionLine(
223
226
  kind: "bash" | "python",
224
227
  source: string,
@@ -231,7 +234,7 @@ function executionLine(
231
234
  : "ok";
232
235
  const lines = lineCount(msg.output);
233
236
  const sourcePreview = formatExecutionSourcePreview(source);
234
- return `→ ${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
237
+ return `→ user-${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
235
238
  }
236
239
 
237
240
  /**
@@ -311,6 +314,18 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
311
314
  // every call repeats `**agent**:`). Cleared whenever a
312
315
  // non-role-labeled line is emitted so the next turn re-labels.
313
316
  let lastWatchedLabel: string | undefined;
317
+ // Emit a watched-mode role label, collapsing consecutive same-role turns
318
+ // under one label (matching the user/assistant paths). Used for the
319
+ // user-attributed `!`/`$` execution lines so the advisor never reads them
320
+ // as agent actions.
321
+ const pushWatchedRole = (label: string, body: string): void => {
322
+ if (lastWatchedLabel === label) {
323
+ lines.push(body, "");
324
+ } else {
325
+ lines.push(label, body, "");
326
+ lastWatchedLabel = label;
327
+ }
328
+ };
314
329
 
315
330
  for (const msg of typed) {
316
331
  switch (msg.role) {
@@ -372,15 +387,25 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
372
387
  case "bashExecution": {
373
388
  const bashMsg = msg as BashExecutionMessage;
374
389
  if (bashMsg.excludeFromContext) break;
375
- lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
376
- lastWatchedLabel = undefined;
390
+ const bashLine = executionLine("bash", bashMsg.command, bashMsg);
391
+ if (opts?.watchedRoles) {
392
+ pushWatchedRole("**user**:", bashLine);
393
+ } else {
394
+ lines.push(bashLine, "");
395
+ lastWatchedLabel = undefined;
396
+ }
377
397
  break;
378
398
  }
379
399
  case "pythonExecution": {
380
400
  const pythonMsg = msg as PythonExecutionMessage;
381
401
  if (pythonMsg.excludeFromContext) break;
382
- lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
383
- lastWatchedLabel = undefined;
402
+ const pythonLine = executionLine("python", pythonMsg.code, pythonMsg);
403
+ if (opts?.watchedRoles) {
404
+ pushWatchedRole("**user**:", pythonLine);
405
+ } else {
406
+ lines.push(pythonLine, "");
407
+ lastWatchedLabel = undefined;
408
+ }
384
409
  break;
385
410
  }
386
411
  case "custom":
@@ -56,14 +56,14 @@ export function parseSessionContent(content: string): {
56
56
  return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
57
57
  }
58
58
 
59
- /** Exported for testing the ≥8MiB streaming path (works on any file size). */
60
- export async function loadEntriesFromFileStream(filePath: string): Promise<{
61
- entries: FileEntry[];
62
- titleSlot: SessionTitleUpdate | undefined;
63
- }> {
64
- const entries: FileEntry[] = [];
59
+ /** Parse session JSONL and visit each entry without retaining prior entries. */
60
+ export async function visitEntriesFromFileStream(
61
+ filePath: string,
62
+ visit: (entry: FileEntry) => void,
63
+ ): Promise<SessionTitleUpdate | undefined> {
65
64
  let titleSlot: SessionTitleUpdate | undefined;
66
65
  let sawFirstLine = false;
66
+ let visitorThrew = false;
67
67
  // Byte buffer (NOT a decoded string): multibyte UTF-8 sequences that straddle
68
68
  // a stream-chunk boundary stay intact, and Bun.JSONL.parseChunk accepts typed
69
69
  // arrays directly. Only the unconsumed remainder is held (≤ one record + a
@@ -75,8 +75,13 @@ export async function loadEntriesFromFileStream(filePath: string): Promise<{
75
75
  const drain = () => {
76
76
  while (buffer.length > 0) {
77
77
  const { values, error, read, done } = Bun.JSONL.parseChunk(buffer);
78
- if (values.length > 0) {
79
- for (const value of values) entries.push(value as FileEntry);
78
+ for (const value of values) {
79
+ try {
80
+ visit(value as FileEntry);
81
+ } catch (err) {
82
+ visitorThrew = true;
83
+ throw err;
84
+ }
80
85
  }
81
86
  if (error) {
82
87
  // Malformed record: skip past the next newline and continue.
@@ -125,10 +130,21 @@ export async function loadEntriesFromFileStream(filePath: string): Promise<{
125
130
  }
126
131
  drain();
127
132
  } catch (err) {
128
- if (isEnoent(err)) return { entries: [], titleSlot: undefined };
133
+ if (visitorThrew) throw err;
134
+ if (isEnoent(err)) return undefined;
129
135
  throw err;
130
136
  }
131
137
 
138
+ return titleSlot;
139
+ }
140
+
141
+ /** Exported for testing — the ≥8MiB streaming path (works on any file size). */
142
+ export async function loadEntriesFromFileStream(filePath: string): Promise<{
143
+ entries: FileEntry[];
144
+ titleSlot: SessionTitleUpdate | undefined;
145
+ }> {
146
+ const entries: FileEntry[] = [];
147
+ const titleSlot = await visitEntriesFromFileStream(filePath, entry => entries.push(entry));
132
148
  return { entries: foldTitleSlot(entries, titleSlot), titleSlot };
133
149
  }
134
150
 
@@ -26,6 +26,7 @@ import {
26
26
  DEFAULT_SHAKE_CONFIG,
27
27
  effectiveReserveTokens,
28
28
  estimateTokens,
29
+ NativeCompactionError,
29
30
  prepareCompaction,
30
31
  resolveBudgetReserveTokens,
31
32
  resolveThresholdTokens,
@@ -34,6 +35,7 @@ import {
34
35
  type SummaryOptions,
35
36
  shouldCompact,
36
37
  shouldUseOpenAiRemoteCompaction,
38
+ shouldUseProviderNativeCompaction,
37
39
  } from "@oh-my-pi/pi-agent-core/compaction";
38
40
  import {
39
41
  DEFAULT_PRUNE_CONFIG,
@@ -1373,7 +1375,11 @@ export class SessionMaintenance {
1373
1375
  }
1374
1376
  }
1375
1377
 
1376
- async resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
1378
+ async resolveContextPromotionTarget(
1379
+ currentModel: Model,
1380
+ contextWindow: number,
1381
+ signal?: AbortSignal,
1382
+ ): Promise<Model | undefined> {
1377
1383
  const availableModels = this.#host.modelRegistry.getAvailable();
1378
1384
  if (availableModels.length === 0) return undefined;
1379
1385
 
@@ -1381,7 +1387,7 @@ export class SessionMaintenance {
1381
1387
  if (!candidate) return undefined;
1382
1388
  if (modelsAreEqual(candidate, currentModel)) return undefined;
1383
1389
  if (candidate.contextWindow == null || candidate.contextWindow <= contextWindow) return undefined;
1384
- const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
1390
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId(), { signal });
1385
1391
  if (!apiKey) return undefined;
1386
1392
  return candidate;
1387
1393
  }
@@ -1454,10 +1460,18 @@ export class SessionMaintenance {
1454
1460
  const candidates =
1455
1461
  precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable());
1456
1462
  const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
1463
+ let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined;
1457
1464
 
1458
1465
  for (const candidate of candidates) {
1459
1466
  const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
1460
1467
  if (!apiKey) continue;
1468
+ if (
1469
+ nativeCompactionFailure &&
1470
+ (candidate.provider !== nativeCompactionFailure.provider ||
1471
+ !shouldUseProviderNativeCompaction(candidate, preparation.settings))
1472
+ ) {
1473
+ throw nativeCompactionFailure.error;
1474
+ }
1461
1475
 
1462
1476
  try {
1463
1477
  return await compact(
@@ -1495,12 +1509,17 @@ export class SessionMaintenance {
1495
1509
  },
1496
1510
  );
1497
1511
  } catch (error) {
1498
- if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) {
1499
- throw error;
1512
+ const id = AIError.classify(error instanceof NativeCompactionError ? error.cause : error, candidate.api);
1513
+ if (AIError.is(id, AIError.Flag.AuthFailed)) continue;
1514
+ if (error instanceof NativeCompactionError) {
1515
+ nativeCompactionFailure ??= { error, provider: candidate.provider };
1516
+ continue;
1500
1517
  }
1518
+ throw error;
1501
1519
  }
1502
1520
  }
1503
1521
 
1522
+ if (nativeCompactionFailure) throw nativeCompactionFailure.error;
1504
1523
  throw this.#buildCompactionAuthError();
1505
1524
  }
1506
1525
 
@@ -2473,6 +2492,7 @@ export class SessionMaintenance {
2473
2492
  const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
2474
2493
  let compactResult: CompactionResult | undefined;
2475
2494
  let lastError: unknown;
2495
+ let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined;
2476
2496
  codexCompaction = createCodexCompactionContext({
2477
2497
  trigger: "auto",
2478
2498
  reason: "context_limit",
@@ -2486,6 +2506,13 @@ export class SessionMaintenance {
2486
2506
  const hasMoreCandidates = candidateIndex < candidates.length - 1;
2487
2507
  const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
2488
2508
  if (!apiKey) continue;
2509
+ if (
2510
+ nativeCompactionFailure &&
2511
+ (candidate.provider !== nativeCompactionFailure.provider ||
2512
+ !shouldUseProviderNativeCompaction(candidate, preparation.settings))
2513
+ ) {
2514
+ throw nativeCompactionFailure.error;
2515
+ }
2489
2516
 
2490
2517
  let attempt = 0;
2491
2518
  while (true) {
@@ -2523,22 +2550,33 @@ export class SessionMaintenance {
2523
2550
  }
2524
2551
 
2525
2552
  const message = error instanceof Error ? error.message : String(error);
2526
- const id = AIError.classify(error, candidate.api);
2553
+ const id = AIError.classify(
2554
+ error instanceof NativeCompactionError ? error.cause : error,
2555
+ candidate.api,
2556
+ );
2527
2557
  if (AIError.is(id, AIError.Flag.AuthFailed)) {
2528
- lastError = this.#buildCompactionAuthError();
2558
+ if (!nativeCompactionFailure) lastError = this.#buildCompactionAuthError();
2529
2559
  break;
2530
2560
  }
2531
2561
  if (AIError.is(id, AIError.Flag.Timeout)) {
2562
+ const nativeFailure = error instanceof NativeCompactionError;
2532
2563
  logger.warn(
2533
- hasMoreCandidates
2534
- ? "Auto-compaction summarization timed out, trying next model"
2535
- : "Auto-compaction summarization timed out, not retrying same model",
2564
+ nativeFailure
2565
+ ? "Provider-native auto-compaction timed out, preserving native failure"
2566
+ : hasMoreCandidates
2567
+ ? "Auto-compaction summarization timed out, trying next model"
2568
+ : "Auto-compaction summarization timed out, not retrying same model",
2536
2569
  {
2537
2570
  error: message,
2538
2571
  model: `${candidate.provider}/${candidate.id}`,
2539
2572
  },
2540
2573
  );
2541
- lastError = error;
2574
+ if (nativeFailure) {
2575
+ nativeCompactionFailure ??= { error, provider: candidate.provider };
2576
+ lastError = nativeCompactionFailure.error;
2577
+ } else {
2578
+ lastError = error;
2579
+ }
2542
2580
  break;
2543
2581
  }
2544
2582
 
@@ -2550,7 +2588,12 @@ export class SessionMaintenance {
2550
2588
  AIError.is(id, AIError.Flag.Transient) ||
2551
2589
  AIError.is(id, AIError.Flag.UsageLimit));
2552
2590
  if (!shouldRetry) {
2553
- lastError = error;
2591
+ if (error instanceof NativeCompactionError) {
2592
+ nativeCompactionFailure ??= { error, provider: candidate.provider };
2593
+ lastError = nativeCompactionFailure.error;
2594
+ } else {
2595
+ lastError = error;
2596
+ }
2554
2597
  break;
2555
2598
  }
2556
2599
 
@@ -2560,6 +2603,11 @@ export class SessionMaintenance {
2560
2603
  // If retry delay is too long (>30s), try next candidate instead of waiting
2561
2604
  const maxAcceptableDelayMs = 30_000;
2562
2605
  if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) {
2606
+ if (error instanceof NativeCompactionError) {
2607
+ nativeCompactionFailure ??= { error, provider: candidate.provider };
2608
+ lastError = nativeCompactionFailure.error;
2609
+ break;
2610
+ }
2563
2611
  logger.warn("Auto-compaction retry delay too long, trying next model", {
2564
2612
  delayMs,
2565
2613
  retryAfterMs,
@@ -35,6 +35,7 @@ import { type BuildSessionContextOptions, buildSessionContext, type SessionConte
35
35
  import {
36
36
  type BranchSummaryEntry,
37
37
  type CompactionEntry,
38
+ type CredentialPinEntry,
38
39
  CURRENT_SESSION_VERSION,
39
40
  type CustomEntry,
40
41
  type CustomMessageEntry,
@@ -179,6 +180,7 @@ function isDraftOnlyMetadataEntry(entry: SessionEntry): boolean {
179
180
  case "thinking_level_change":
180
181
  case "service_tier_change":
181
182
  case "mode_change":
183
+ case "credential_pin":
182
184
  return true;
183
185
  default:
184
186
  return false;
@@ -1420,6 +1422,30 @@ export class SessionManager {
1420
1422
  await this.#rewriteAtomically();
1421
1423
  }
1422
1424
 
1425
+ /** Persist this session's transcript as a newly identified OMP session. */
1426
+ async persistCopy(
1427
+ options?: { sessionDir?: string; suppressBreadcrumb?: boolean },
1428
+ storage: SessionStorage = new FileSessionStorage(),
1429
+ ): Promise<SessionManager> {
1430
+ const sessionDir = options?.sessionDir ?? SessionManager.getDefaultSessionDir(this.#cwd, undefined, storage);
1431
+ const manager = new SessionManager(this.#cwd, sessionDir, true, storage);
1432
+ manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
1433
+ manager.#resetToNewSession();
1434
+ manager.#sessionName = this.#sessionName;
1435
+ manager.#titleSource = this.#titleSource;
1436
+ manager.#titleUpdatedAt = this.#titleUpdatedAt;
1437
+ manager.#header.title = this.#sessionName;
1438
+ manager.#header.titleSource = this.#titleSource;
1439
+ manager.#additionalDirectories = [...this.#additionalDirectories];
1440
+ manager.#header.additionalDirectories =
1441
+ manager.#additionalDirectories.length > 0 ? [...manager.#additionalDirectories] : undefined;
1442
+ manager.#entries = structuredClone(this.#entries);
1443
+ manager.#index.rebuild(manager.#entries);
1444
+ manager.#forceFileCreation = true;
1445
+ await manager.#rewriteAtomically();
1446
+ return manager;
1447
+ }
1448
+
1423
1449
  /**
1424
1450
  * Stage a synchronous group of entry appends and publish the resulting full
1425
1451
  * journal with one atomic replace. A failed publish removes only the staged
@@ -2038,6 +2064,41 @@ export class SessionManager {
2038
2064
  return [...names];
2039
2065
  }
2040
2066
 
2067
+ /** Append a credential pin recording which OAuth account served `provider`. */
2068
+ appendCredentialPin(provider: string, hash: string): string {
2069
+ const entry: CredentialPinEntry = {
2070
+ type: "credential_pin",
2071
+ ...this.#freshEntryFields(),
2072
+ provider,
2073
+ hash,
2074
+ };
2075
+ this.#recordEntry(entry);
2076
+ return entry.id;
2077
+ }
2078
+
2079
+ /**
2080
+ * Latest credential pin per provider on the current branch (root → leaf),
2081
+ * with the effective last-use time of the pinned account.
2082
+ *
2083
+ * Pins are appended only when the serving account *changes*, so a long
2084
+ * session on one account carries a single old pin entry. Any assistant turn
2085
+ * for the same provider after that pin was necessarily served by the pinned
2086
+ * account, so its timestamp advances `lastUsedAt` — a resume seconds after
2087
+ * the last turn seeds a warm sticky instead of a stale one.
2088
+ */
2089
+ getCredentialPins(): Map<string, { hash: string; lastUsedAt: number }> {
2090
+ const pins = new Map<string, { hash: string; lastUsedAt: number }>();
2091
+ for (const entry of this.getBranch()) {
2092
+ if (entry.type === "credential_pin") {
2093
+ pins.set(entry.provider, { hash: entry.hash, lastUsedAt: new Date(entry.timestamp).getTime() });
2094
+ } else if (entry.type === "message" && entry.message.role === "assistant") {
2095
+ const pin = pins.get(entry.message.provider);
2096
+ if (pin) pin.lastUsedAt = Math.max(pin.lastUsedAt, entry.message.timestamp);
2097
+ }
2098
+ }
2099
+ return pins;
2100
+ }
2101
+
2041
2102
  getLeafId(): string | null {
2042
2103
  return this.#index.leafId();
2043
2104
  }
@@ -1,6 +1,6 @@
1
1
  import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Model } from "@oh-my-pi/pi-ai";
3
- import { logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
3
+ import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
4
4
  import { reset as resetCapabilities } from "../capability";
5
5
  import type { ModelRegistry } from "../config/model-registry";
6
6
  import { formatModelString } from "../config/model-resolver";
@@ -159,6 +159,18 @@ export function projectMountedMCPXdevGuidance(routes: Iterable<MountedMCPToolRou
159
159
 
160
160
  const XDEV_MOUNT_NOTICE_MESSAGE_TYPE = "xdev-mount-notice";
161
161
 
162
+ /**
163
+ * Structured payload persisted on each {@link XDEV_MOUNT_NOTICE_MESSAGE_TYPE}
164
+ * custom message. Lets a resumed session reconstruct which dynamic devices the
165
+ * model has already been told about, so reconnecting hosts do not re-announce
166
+ * (and re-splice a redundant developer message that busts the provider
167
+ * prompt-cache prefix).
168
+ */
169
+ interface XdevMountNoticeDetails {
170
+ added: string[];
171
+ removed: string[];
172
+ }
173
+
162
174
  /** Owns tool registration, presentation, prompt rebuilding, skills, and permissions. */
163
175
  export class SessionTools {
164
176
  readonly #host: SessionToolsHost;
@@ -172,6 +184,14 @@ export class SessionTools {
172
184
  #rpcHostToolNames = new Set<string>();
173
185
  #xdev: XdevState | undefined;
174
186
  #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
187
+ /**
188
+ * Dynamic (`xd://`) devices the model has already been told are mounted.
189
+ * Seeded lazily from persisted history on resume (see
190
+ * {@link #ensureAnnouncedMountsSeeded}) and updated as notices are emitted, so
191
+ * a host reconnect that re-mounts the same device does not re-announce it.
192
+ */
193
+ #announcedMounts = new Set<string>();
194
+ #announcedMountsSeeded = false;
175
195
  #presentationPinnedToolNames: ReadonlySet<string> | undefined;
176
196
  #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
177
197
  #baseSystemPrompt: string[];
@@ -674,26 +694,108 @@ export class SessionTools {
674
694
  this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
675
695
  }
676
696
 
697
+ /**
698
+ * Forget the announced-mount baseline for a replaced transcript. Called when
699
+ * session history is swapped wholesale (`/new`, `switchSession`, `branch`): the
700
+ * previous transcript's persisted notices no longer apply, so the next notice
701
+ * re-seeds from the new history and a device reconnecting into it announces
702
+ * again.
703
+ *
704
+ * The pending delta is deliberately preserved: it holds mounts that are still
705
+ * live but not yet delivered to the model, and `branch()` does not rebuild the
706
+ * base system prompt, so dropping it would leave the branched transcript
707
+ * unaware of a still-mounted device that no later refresh would re-queue.
708
+ */
709
+ resetAnnouncedMounts(): void {
710
+ this.#announcedMounts.clear();
711
+ this.#announcedMountsSeeded = false;
712
+ }
713
+
714
+ /**
715
+ * Seed {@link #announcedMounts} from persisted mount notices the first time a
716
+ * notice is consumed. On resume the in-memory mount set is rebuilt from
717
+ * scratch, so without replaying history every already-announced dynamic device
718
+ * would look freshly mounted and re-announce.
719
+ */
720
+ #ensureAnnouncedMountsSeeded(): void {
721
+ if (this.#announcedMountsSeeded) return;
722
+ this.#announcedMountsSeeded = true;
723
+ for (const message of this.#host.agent.state.messages) {
724
+ if (message.role !== "custom" || message.customType !== XDEV_MOUNT_NOTICE_MESSAGE_TYPE) continue;
725
+ const details = message.details;
726
+ if (
727
+ isRecord(details) &&
728
+ Array.isArray(details.added) &&
729
+ details.added.every(name => typeof name === "string") &&
730
+ Array.isArray(details.removed) &&
731
+ details.removed.every(name => typeof name === "string")
732
+ ) {
733
+ for (const name of details.added) this.#announcedMounts.add(name);
734
+ for (const name of details.removed) this.#announcedMounts.delete(name);
735
+ continue;
736
+ }
737
+
738
+ // Releases before structured notice details persisted only the rendered
739
+ // prompt. Replay its two stable inventory sections so the first resume
740
+ // after upgrading does not re-announce every dynamic device once.
741
+ if (typeof message.content !== "string") continue;
742
+ let section: "added" | "removed" | undefined;
743
+ for (const line of message.content.split("\n")) {
744
+ if (line === "These tools became available:") {
745
+ section = "added";
746
+ continue;
747
+ }
748
+ if (line.startsWith("No longer mounted")) {
749
+ section = "removed";
750
+ continue;
751
+ }
752
+ if (line === "Configured inline device docs:" || line === "</system-notice>") break;
753
+ if (line.startsWith("Read `xd://<tool>`")) {
754
+ section = undefined;
755
+ continue;
756
+ }
757
+ if (!section) continue;
758
+ const match = /^- xd:\/\/(\S+?)(?:\s+—|$)/.exec(line);
759
+ const name = match?.[1];
760
+ if (!name) continue;
761
+ if (section === "added") this.#announcedMounts.add(name);
762
+ else this.#announcedMounts.delete(name);
763
+ }
764
+ }
765
+ }
766
+
677
767
  /** Consumes the hidden notice for unannounced `xd://` mount changes. */
678
- takePendingXdevMountNotice(): CustomMessage | undefined {
768
+ takePendingXdevMountNotice(): CustomMessage<XdevMountNoticeDetails> | undefined {
679
769
  const pending = this.#pendingXdevMountDelta;
680
770
  if (!pending) return undefined;
681
771
  this.#pendingXdevMountDelta = undefined;
772
+ this.#ensureAnnouncedMountsSeeded();
773
+ // Only announce a net change relative to what the model already knows (from
774
+ // this session and persisted history): a re-mount of an already-announced
775
+ // device — the common resume/reconnect case — and an unmount for a device
776
+ // it was never told about are both suppressed, keeping the provider prompt
777
+ // cache prefix byte-stable across resumes.
778
+ const addedNames = [...pending.added].filter(name => !this.#announcedMounts.has(name));
779
+ const removedNames = [...pending.removed].filter(name => this.#announcedMounts.has(name));
780
+ if (addedNames.length === 0 && removedNames.length === 0) return undefined;
682
781
  const summaries = new Map(this.#xdev ? xdevEntries(this.#xdev).map(entry => [entry.name, entry.summary]) : []);
683
- const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
684
- const removed = [...pending.removed].map(name => ({ name }));
782
+ const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" }));
783
+ const removed = removedNames.map(name => ({ name }));
685
784
  const docs = this.#xdev
686
785
  ? xdevDocsFor(
687
786
  this.#xdev,
688
- pending.added,
787
+ new Set(addedNames),
689
788
  this.#host.settings.get("tools.xdevDocs"),
690
789
  this.#host.settings.get("tools.xdevInlineDevices"),
691
790
  )
692
791
  : "";
792
+ for (const name of addedNames) this.#announcedMounts.add(name);
793
+ for (const name of removedNames) this.#announcedMounts.delete(name);
693
794
  return {
694
795
  role: "custom",
695
796
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
696
797
  content: prompt.render(xdevMountNoticePrompt, { added, removed, docs }),
798
+ details: { added: addedNames, removed: removedNames },
697
799
  attribution: "agent",
698
800
  display: false,
699
801
  timestamp: Date.now(),
@@ -58,6 +58,7 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
58
58
  textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
59
59
  streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
60
60
  streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
61
+ maxRetryDelayMs: streamOptions?.maxRetryDelayMs ?? settings.get("retry.maxDelayMs"),
61
62
  maxInFlightRequests: validateProviderMaxInFlightRequests(
62
63
  streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
63
64
  ),