@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
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
4
4
  import * as AIError from "@oh-my-pi/pi-ai/error";
5
+ import { raceWithSignal } from "@oh-my-pi/pi-ai/utils/abort";
5
6
  import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
6
7
  import { logger } from "@oh-my-pi/pi-utils";
7
8
  import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
@@ -47,7 +48,7 @@ export interface AdvisorRuntimeHost {
47
48
  * recovery path must never replay the full primary transcript.
48
49
  * Optional: hosts that omit it get no proactive maintenance.
49
50
  */
50
- maintainContext?(incomingTokens: number): Promise<boolean>;
51
+ maintainContext?(incomingTokens: number, signal: AbortSignal): Promise<boolean>;
51
52
  /**
52
53
  * Called immediately before each `agent.prompt(batch)` cycle. Lets the host
53
54
  * clear per-update advisor state — currently the one-advise-per-update gate
@@ -67,6 +68,7 @@ export interface AdvisorRuntimeHost {
67
68
  onTurnError?(
68
69
  error: unknown,
69
70
  failedMessages: readonly AgentMessage[],
71
+ signal: AbortSignal,
70
72
  ): Promise<boolean | undefined> | boolean | undefined;
71
73
  /** Called after a successful advisor turn so the host can finish fallback lifecycle reporting. */
72
74
  onTurnSuccess?(): Promise<void> | void;
@@ -76,6 +78,8 @@ export interface AdvisorRuntimeHost {
76
78
  * recovery (credential switch, fallback chain) declined. Cleared only by
77
79
  * an explicit reset (`/new`, config rebuild, session restart). */
78
80
  notifyQuotaExhausted?(): void;
81
+ /** Stable identity for the live advisor model. Used to restore full transcript rendering after a model switch. */
82
+ getModelIdentity?(): string;
79
83
  }
80
84
 
81
85
  /**
@@ -228,7 +232,6 @@ const MAX_COALESCE_ROUNDS = 3;
228
232
  const MAX_QUARANTINE_RETRIES = 2;
229
233
 
230
234
  const ADVISOR_RENDER_OPTIONS = {
231
- includeThinking: true,
232
235
  includeToolIntent: true,
233
236
  watchedRoles: true,
234
237
  expandPrimaryContext: true,
@@ -285,11 +288,17 @@ export class AdvisorRuntime {
285
288
  #advisorRegexSecretValues = new Set<string>();
286
289
  #pending: PendingDelta[] = [];
287
290
  #busy = false;
291
+ #sessionTransitionPaused = false;
292
+ #promptInFlight: Promise<void> | undefined;
293
+ #iterationAbort: AbortController | undefined;
288
294
  #backlog = 0;
289
295
  #consecutiveFailures = 0;
290
296
  #failureNotified = false;
291
297
  /** Consecutive quarantined turns since the last success/reset (issue #6661). */
292
298
  #consecutiveQuarantines = 0;
299
+ /** Whether primary reasoning is included in advisor deltas for the current model. */
300
+ #includeThinking = true;
301
+ #modelIdentity: string | undefined;
293
302
  /** Completed 3-failure backlog-drop cycles since the last success/reset. */
294
303
  #droppedBacklogs = 0;
295
304
  /**
@@ -428,6 +437,7 @@ export class AdvisorRuntime {
428
437
  }
429
438
 
430
439
  dispose(): void {
440
+ this.#iterationAbort?.abort("advisor disposed");
431
441
  this.disposed = true;
432
442
  this.#epoch++;
433
443
  this.#pending = [];
@@ -490,6 +500,31 @@ export class AdvisorRuntime {
490
500
  });
491
501
  }
492
502
 
503
+ /** Stop new advisor work and wait only for the active prompt's recorder-visible events. */
504
+ pauseForSessionTransition(): Promise<void> {
505
+ if (!this.#sessionTransitionPaused) {
506
+ this.#sessionTransitionPaused = true;
507
+ this.#wakeAllWaiters();
508
+ this.#iterationAbort?.abort("advisor session transition");
509
+ try {
510
+ this.agent.abort("advisor session transition");
511
+ } catch {}
512
+ }
513
+ return (
514
+ this.#promptInFlight?.then(
515
+ () => {},
516
+ () => {},
517
+ ) ?? Promise.resolve()
518
+ );
519
+ }
520
+
521
+ /** Continue queued work after a session transition rolls back or preserves the conversation. */
522
+ resumeAfterSessionTransition(): void {
523
+ if (!this.#sessionTransitionPaused) return;
524
+ this.#sessionTransitionPaused = false;
525
+ if (!this.#quotaExhausted && !this.#halted) void this.#drain();
526
+ }
527
+
493
528
  /**
494
529
  * Re-prime the advisor after a history rewrite (compaction, session
495
530
  * switch/resume, branch). Clears the advisor's own (non-persisted) context
@@ -498,7 +533,9 @@ export class AdvisorRuntime {
498
533
  * leaving it blind to everything before the rewrite.
499
534
  */
500
535
  reset(): void {
536
+ this.#iterationAbort?.abort("advisor reset");
501
537
  this.#epoch++;
538
+ this.#sessionTransitionPaused = false;
502
539
  this.#quotaExhausted = false;
503
540
  this.#halted = false;
504
541
  this.#failing = false;
@@ -530,13 +567,23 @@ export class AdvisorRuntime {
530
567
  this.#wakeAllWaiters();
531
568
  }
532
569
 
570
+ #syncModelIdentity(): void {
571
+ const identity = this.host.getModelIdentity?.();
572
+ if (identity === undefined || identity === this.#modelIdentity) return;
573
+ this.#modelIdentity = identity;
574
+ this.#includeThinking = true;
575
+ }
576
+
533
577
  #formatRawDelta(rawMessages: AgentMessage[], wip = false): string | null {
534
578
  const delta = rawMessages
535
579
  .filter(message => !(message.role === "custom" && message.customType === "advisor"))
536
580
  .map(message => this.#dedupContextMessage(message));
537
581
  if (delta.length === 0) return null;
538
582
  const obfuscator = this.host.obfuscator;
539
- let md = formatSessionHistoryMarkdown(delta, ADVISOR_RENDER_OPTIONS);
583
+ let md = formatSessionHistoryMarkdown(delta, {
584
+ ...ADVISOR_RENDER_OPTIONS,
585
+ includeThinking: this.#includeThinking,
586
+ });
540
587
  if (!md.trim()) return null;
541
588
  if (obfuscator?.hasSecrets()) {
542
589
  let discoveredNewRegexSecretValue = false;
@@ -570,7 +617,7 @@ export class AdvisorRuntime {
570
617
  ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues)
571
618
  : message,
572
619
  ),
573
- ADVISOR_RENDER_OPTIONS,
620
+ { ...ADVISOR_RENDER_OPTIONS, includeThinking: this.#includeThinking },
574
621
  );
575
622
  md = obfuscator.obfuscate(md, this.#advisorRegexSecretValues);
576
623
  }
@@ -696,6 +743,7 @@ export class AdvisorRuntime {
696
743
  epoch: number,
697
744
  initial: PendingDelta[],
698
745
  recoveringOverflow: boolean,
746
+ signal: AbortSignal,
699
747
  ): Promise<{
700
748
  batch: string | null;
701
749
  rawMessages: AgentMessage[];
@@ -712,11 +760,12 @@ export class AdvisorRuntime {
712
760
  let wip = initial.at(-1)?.wip ?? false;
713
761
 
714
762
  for (let round = 0; round < MAX_COALESCE_ROUNDS; round++) {
763
+ if (this.#sessionTransitionPaused) break;
715
764
  if (this.host.maintainContext) {
716
765
  const incomingTokens = estimateTokens({ role: "user", content: batchText, timestamp: Date.now() });
717
766
  let shouldResetContext = false;
718
767
  try {
719
- shouldResetContext = await this.host.maintainContext(incomingTokens);
768
+ shouldResetContext = await this.host.maintainContext(incomingTokens, signal);
720
769
  } catch (err) {
721
770
  logger.debug("advisor context maintenance failed", { err: String(err) });
722
771
  }
@@ -733,6 +782,7 @@ export class AdvisorRuntime {
733
782
  // remain queued and ship as their own subsequent batch.
734
783
  if (round > 0) {
735
784
  const lateItems = this.#pending.splice(0);
785
+ initial.push(...lateItems);
736
786
  turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
737
787
  if (lateItems.length > 0) {
738
788
  wip = lateItems.at(-1)!.wip;
@@ -769,6 +819,7 @@ export class AdvisorRuntime {
769
819
  // update WIP state, and re-check the maintenance budget.
770
820
  const late = this.#pending.splice(0);
771
821
  if (late.length === 0) break;
822
+ initial.push(...late);
772
823
  batchText = [batchText, ...late.map(b => b.text)].join("\n\n");
773
824
  rawMessages = rawMessages.concat(late.flatMap(b => b.rawMessages));
774
825
  turns += late.reduce((sum, b) => sum + b.turns, 0);
@@ -802,10 +853,12 @@ export class AdvisorRuntime {
802
853
  }
803
854
 
804
855
  async #drain(): Promise<void> {
805
- if (this.#busy) return;
856
+ if (this.#busy || this.#sessionTransitionPaused) return;
806
857
  this.#busy = true;
807
858
  try {
808
- while (!this.disposed && this.#pending.length) {
859
+ this.#syncModelIdentity();
860
+ while (!this.disposed && !this.#sessionTransitionPaused && this.#pending.length) {
861
+ this.#syncModelIdentity();
809
862
  let popped: PendingDelta[];
810
863
  if (this.#pending[0]?.overflowRecovery) {
811
864
  const recovery = this.#pending.shift();
@@ -814,6 +867,8 @@ export class AdvisorRuntime {
814
867
  } else {
815
868
  popped = this.#pending.splice(0);
816
869
  }
870
+ const iterationAbort = new AbortController();
871
+ this.#iterationAbort = iterationAbort;
817
872
  const epoch = this.#epoch;
818
873
  for (const delta of popped) {
819
874
  if (delta.renderRevision === this.#renderRevision) continue;
@@ -822,10 +877,19 @@ export class AdvisorRuntime {
822
877
  delta.renderRevision = this.#renderRevision;
823
878
  }
824
879
  const recoveringOverflow = popped.some(delta => delta.overflowRecovery === true);
825
- const result = await this.#collectAndMaintainBatch(epoch, popped, recoveringOverflow);
880
+ const result = await this.#collectAndMaintainBatch(
881
+ epoch,
882
+ popped,
883
+ recoveringOverflow,
884
+ iterationAbort.signal,
885
+ );
826
886
 
827
887
  // Epoch was invalidated during batch collection; restart the loop.
828
888
  if (result === null) continue;
889
+ if (this.#sessionTransitionPaused) {
890
+ this.#pending.unshift(...popped);
891
+ continue;
892
+ }
829
893
 
830
894
  const { batch, rawMessages, finalTurns, wip, resetContext } = result;
831
895
 
@@ -847,7 +911,13 @@ export class AdvisorRuntime {
847
911
  // Reset the host's per-update advisor state (one-advise-per-update
848
912
  // gate) before each model cycle so the new batch starts fresh.
849
913
  this.host.beginAdvisorUpdate?.();
850
- await this.agent.prompt(batch);
914
+ const prompt = this.agent.prompt(batch);
915
+ this.#promptInFlight = prompt;
916
+ try {
917
+ await prompt;
918
+ } finally {
919
+ if (this.#promptInFlight === prompt) this.#promptInFlight = undefined;
920
+ }
851
921
  // Agent.#runLoop catches provider/stream failures internally and
852
922
  // resolves prompt() cleanly with stopReason: "error". Treat that
853
923
  // as a failed turn so endpoint rejections trip the retry path.
@@ -868,12 +938,17 @@ export class AdvisorRuntime {
868
938
  this.#consecutiveQuarantines = 0;
869
939
  if (this.host.onTurnSuccess) {
870
940
  try {
871
- await this.host.onTurnSuccess();
941
+ await raceWithSignal(Promise.resolve(this.host.onTurnSuccess()), iterationAbort.signal);
872
942
  } catch (hookErr) {
873
943
  logger.debug("advisor onTurnSuccess hook failed", { err: String(hookErr) });
874
944
  }
875
945
  }
876
946
  } catch (err) {
947
+ if (this.#sessionTransitionPaused) {
948
+ this.#rollbackFailedTurn(messageSnapshot);
949
+ this.#pending.unshift(...popped);
950
+ continue;
951
+ }
877
952
  // reset()/dispose() aborts the in-flight prompt; treat it as a
878
953
  // reset, not a transient failure — drop the stale batch.
879
954
  if (this.#epoch !== epoch) continue;
@@ -885,6 +960,9 @@ export class AdvisorRuntime {
885
960
  this.#wakeAllWaiters();
886
961
  const failedMessages = this.agent.state.messages.slice(messageSnapshot);
887
962
  const terminalFailure = this.#terminalAssistantFailure(messageSnapshot);
963
+ const classifierRefusal =
964
+ (terminalFailure !== undefined && isClassifierRefusal(terminalFailure)) ||
965
+ AIError.is(AIError.classify(err), AIError.Flag.ContentBlocked);
888
966
  const terminalFailureId =
889
967
  terminalFailure === undefined ? undefined : AIError.classifyMessage(terminalFailure);
890
968
  const contextOverflow =
@@ -900,12 +978,43 @@ export class AdvisorRuntime {
900
978
  AIError.is(terminalFailureId, AIError.Flag.ContextOverflow);
901
979
  this.#rollbackFailedTurn(messageSnapshot);
902
980
  logger.debug("advisor turn failed", { err: String(err) });
981
+ if (classifierRefusal) {
982
+ if (this.#includeThinking) {
983
+ this.#includeThinking = false;
984
+ const strippedBatch = this.#formatRawDelta(rawMessages, wip);
985
+ if (strippedBatch) {
986
+ this.#pending.unshift({
987
+ text: strippedBatch,
988
+ rawMessages,
989
+ renderRevision: this.#renderRevision,
990
+ turns: finalTurns,
991
+ wip,
992
+ overflowRecovery: recoveringOverflow || undefined,
993
+ });
994
+ logger.debug("advisor refusal recovered by stripping primary reasoning");
995
+ continue;
996
+ }
997
+ }
998
+ this.#notifyFailureOnce(err);
999
+ this.#clearSeenContext();
1000
+ this.#backlog = Math.max(0, this.#backlog - finalTurns);
1001
+ this.#notifyWaiters();
1002
+ continue;
1003
+ }
903
1004
  let recovered = false;
904
1005
  try {
905
- recovered = (await this.host.onTurnError?.(err, failedMessages)) === true;
1006
+ recovered =
1007
+ (await raceWithSignal(
1008
+ Promise.resolve(this.host.onTurnError?.(err, failedMessages, iterationAbort.signal)),
1009
+ iterationAbort.signal,
1010
+ )) === true;
906
1011
  } catch (hookErr) {
907
1012
  logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
908
1013
  }
1014
+ if (this.#sessionTransitionPaused) {
1015
+ this.#pending.unshift(...popped);
1016
+ continue;
1017
+ }
909
1018
  if (err instanceof AdvisorOutputQuarantinedError) {
910
1019
  // A quarantine discards the advisor's whole turn before dispatch, so
911
1020
  // its advice never reaches the primary. One re-prime is allowed to
@@ -1020,7 +1129,15 @@ export class AdvisorRuntime {
1020
1129
  wip,
1021
1130
  overflowRecovery: recoveringOverflow || undefined,
1022
1131
  });
1023
- await Bun.sleep(this.retryDelayMs);
1132
+ if (this.retryDelayMs <= 0) {
1133
+ await Bun.sleep(0);
1134
+ } else {
1135
+ try {
1136
+ await raceWithSignal(Bun.sleep(this.retryDelayMs), iterationAbort.signal);
1137
+ } catch (sleepError) {
1138
+ if (!iterationAbort.signal.aborted) throw sleepError;
1139
+ }
1140
+ }
1024
1141
  }
1025
1142
  }
1026
1143
  }
@@ -1031,11 +1148,20 @@ export class AdvisorRuntime {
1031
1148
  }
1032
1149
  }
1033
1150
  } finally {
1151
+ this.#iterationAbort = undefined;
1034
1152
  this.#busy = false;
1035
1153
  }
1036
1154
  }
1037
1155
  }
1038
1156
 
1157
+ /** Mirrors turn recovery's refusal classification and retains AIError's provider-neutral content-block fallback. */
1158
+ function isClassifierRefusal(message: AssistantMessage): boolean {
1159
+ if (message.stopReason !== "error") return false;
1160
+ const stopType = message.stopDetails?.type;
1161
+ if (stopType === "refusal" || stopType === "sensitive") return true;
1162
+ return AIError.is(AIError.classifyMessage(message), AIError.Flag.ContentBlocked);
1163
+ }
1164
+
1039
1165
  /**
1040
1166
  * The only malformed advisor turn shape: the prompt resolved but produced no
1041
1167
  * assistant response at all. Everything an assistant message carries — advice,
@@ -1,7 +1,9 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
4
  import type { Message, UserMessage } from "@oh-my-pi/pi-ai";
4
5
  import { logger } from "@oh-my-pi/pi-utils";
6
+ import { visitEntriesFromFileStream } from "../session/session-loader";
5
7
  import { SessionManager } from "../session/session-manager";
6
8
 
7
9
  /**
@@ -31,6 +33,60 @@ export function isAdvisorTranscriptName(name: string): boolean {
31
33
  );
32
34
  }
33
35
 
36
+ /**
37
+ * Sum the advisor spend already persisted next to a primary session transcript,
38
+ * keyed by advisor slug.
39
+ *
40
+ * The ledger a session keeps in memory only covers the current process, so a
41
+ * resumed session would report zero until the next advisor turn. The recorded
42
+ * transcripts are the durable copy of exactly the same finalized messages, so
43
+ * they are read back through the shared loader - no lock, no writer, and no
44
+ * second parser to keep in step with the session format.
45
+ *
46
+ * Only the session's own advisors count: subagent advisors write to
47
+ * `<session>/<SubId>/__advisor.jsonl`, and their spend belongs to the subagent,
48
+ * not to this roster. Hence the scan stays at the top level of the directory.
49
+ */
50
+ export async function loadAdvisorTranscriptCosts(sessionFile: string | undefined): Promise<Map<string, number>> {
51
+ const costs = new Map<string, number>();
52
+ if (!sessionFile?.endsWith(JSONL_SUFFIX)) return costs;
53
+ const directory = sessionFile.slice(0, -JSONL_SUFFIX.length);
54
+ const dirents = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
55
+ for (const dirent of dirents) {
56
+ if (!dirent.isFile() || !isAdvisorTranscriptName(dirent.name)) continue;
57
+ const slug =
58
+ dirent.name === ADVISOR_TRANSCRIPT_FILENAME
59
+ ? ""
60
+ : dirent.name.slice(`${ADVISOR_TRANSCRIPT_STEM}.`.length, -JSONL_SUFFIX.length);
61
+ let total = 0;
62
+ let validHeader: boolean | undefined;
63
+ try {
64
+ await visitEntriesFromFileStream(path.join(directory, dirent.name), entry => {
65
+ const isObject = typeof entry === "object" && entry !== null;
66
+ if (validHeader === undefined) {
67
+ validHeader = isObject && entry.type === "session" && typeof entry.id === "string";
68
+ return;
69
+ }
70
+ // A syntactically valid but non-object entry (e.g. a bare `null`
71
+ // line) must cost only itself, not crash entry.type access and
72
+ // discard everything accumulated for this transcript.
73
+ if (!validHeader || !isObject || entry.type !== "message") return;
74
+ const message = entry.message;
75
+ if (!message || typeof message !== "object" || message.role !== "assistant") return;
76
+ // One malformed usage block must cost that entry only, not the
77
+ // whole transcript's total.
78
+ const total_ = message.usage?.cost?.total;
79
+ if (typeof total_ === "number" && Number.isFinite(total_)) total += total_;
80
+ });
81
+ } catch (err) {
82
+ logger.debug("advisor transcript cost read failed", { file: dirent.name, err: String(err) });
83
+ continue;
84
+ }
85
+ if (total > 0) costs.set(slug, total);
86
+ }
87
+ return costs;
88
+ }
89
+
34
90
  /**
35
91
  * Append-only persister for an advisor agent's transcript.
36
92
  *
@@ -5,7 +5,8 @@
5
5
  * {@link Effort}, clamped into the active model's supported range (never below
6
6
  * {@link Effort.Low}). Two backends, selected by `providers.autoThinkingModel`:
7
7
  *
8
- * - `online` (default): a smol model classifies into `low|medium|high|xhigh`.
8
+ * - `online` (default): a smol model classifies into `low|medium|high|xhigh`,
9
+ * plus `max` when the target model exposes that tier.
9
10
  * - a local key: an on-device memory model classifies into the coarser
10
11
  * `trivial|moderate|hard` scheme (3-class is more reliable than 4-way ordinal
11
12
  * on sub-2B models), mapped to `low|high|xhigh`.
@@ -14,6 +15,7 @@
14
15
  * the caller falls back to a concrete level and continues the turn.
15
16
  */
16
17
  import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
18
+ import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
17
19
  import { prompt } from "@oh-my-pi/pi-utils";
18
20
 
19
21
  import type { ModelRegistry } from "../config/model-registry";
@@ -30,7 +32,31 @@ import {
30
32
  } from "../tiny/models";
31
33
  import { tinyModelClient } from "../tiny/title-client";
32
34
 
33
- const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt);
35
+ /**
36
+ * Rendered classifier prompts, keyed by whether `max` is offered as a label.
37
+ * Two variants only, so both are memoized on first use.
38
+ */
39
+ const DIFFICULTY_SYSTEM_PROMPTS: Partial<Record<"max" | "xhigh", string>> = {};
40
+
41
+ /**
42
+ * Highest effort this turn's classification may resolve to: the configured
43
+ * ceiling, further limited by what the target model actually exposes. The
44
+ * default keeps `auto` one tier below the top, so only an explicit
45
+ * `ultrathink` reaches {@link Effort.Max}.
46
+ */
47
+ function autoEffortCeiling(deps: ClassifyDifficultyDeps): Effort {
48
+ if (deps.settings.get("providers.autoThinkingMaxEffort") !== Effort.Max) return Effort.XHigh;
49
+ return getSupportedEfforts(deps.model).includes(Effort.Max) ? Effort.Max : Effort.XHigh;
50
+ }
51
+
52
+ function difficultySystemPromptFor(ceiling: Effort): string {
53
+ const key = ceiling === Effort.Max ? "max" : "xhigh";
54
+ const cached = DIFFICULTY_SYSTEM_PROMPTS[key];
55
+ if (cached !== undefined) return cached;
56
+ const rendered = prompt.render(difficultySystemPrompt, { allowMax: key === "max" });
57
+ DIFFICULTY_SYSTEM_PROMPTS[key] = rendered;
58
+ return rendered;
59
+ }
34
60
 
35
61
  /** Local classifiers occasionally need more room for chat-template boilerplate. */
36
62
  const LOCAL_ANSWER_MAX_TOKENS = 16;
@@ -64,14 +90,18 @@ export async function classifyDifficulty(
64
90
  ): Promise<Effort | undefined> {
65
91
  const backend = deps.settings.get("providers.autoThinkingModel");
66
92
  const input = preprocessTinyMessage(promptText);
67
- const effort =
68
- backend === ONLINE_AUTO_THINKING_MODEL_KEY
69
- ? await classifyOnline(input, deps)
70
- : await classifyLocal(input, backend, deps);
71
- return clampAutoThinkingEffort(deps.model, effort);
93
+ const online = backend === ONLINE_AUTO_THINKING_MODEL_KEY;
94
+ // The 3-bucket local classifier cannot select `max`, so its ceiling stays at
95
+ // XHigh whatever the setting says — otherwise a sparse ladder would snap its
96
+ // `hard` bucket up to a tier it never chose.
97
+ const ceiling = online ? autoEffortCeiling(deps) : Effort.XHigh;
98
+ const effort = online ? await classifyOnline(input, deps, ceiling) : await classifyLocal(input, backend, deps);
99
+ // The ceiling goes into the clamp itself: capping the request alone is not
100
+ // enough, because a sparse ladder snaps an excluded request back up.
101
+ return clampAutoThinkingEffort(deps.model, effort, ceiling);
72
102
  }
73
103
 
74
- async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
104
+ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps, ceiling: Effort): Promise<Effort> {
75
105
  const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable());
76
106
  const model = resolved?.model;
77
107
  if (!model) {
@@ -88,7 +118,7 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Prom
88
118
  const response = await completeSimple(
89
119
  model,
90
120
  {
91
- systemPrompt: [DIFFICULTY_SYSTEM_PROMPT],
121
+ systemPrompt: [difficultySystemPromptFor(ceiling)],
92
122
  messages: [{ role: "user", content: input, timestamp: Date.now() }],
93
123
  },
94
124
  {
@@ -134,7 +164,13 @@ async function classifyLocal(input: string, modelKey: string, deps: ClassifyDiff
134
164
  return effort;
135
165
  }
136
166
 
137
- /** Map the online 4-way level keyword to an {@link Effort}; earliest match wins. */
167
+ /**
168
+ * Map an online level keyword to an {@link Effort}; earliest match wins.
169
+ *
170
+ * `max` is only offered to the classifier when the target model exposes that
171
+ * tier, but it is always parsed: an unsupported `max` is snapped back down by
172
+ * {@link clampAutoThinkingEffort} rather than failing the turn.
173
+ */
138
174
  export function parseDifficultyLevel(text: string): Effort | undefined {
139
175
  const lower = text.toLowerCase();
140
176
  const candidates: Array<[number, Effort]> = [];
@@ -142,6 +178,8 @@ export function parseDifficultyLevel(text: string): Effort | undefined {
142
178
  // inside "xhigh" (no word boundary between `x` and `h`), so the two never collide.
143
179
  const xhigh = lower.search(/x[\s_-]?high/);
144
180
  if (xhigh >= 0) candidates.push([xhigh, Effort.XHigh]);
181
+ const max = lower.search(/\bmax\b/);
182
+ if (max >= 0) candidates.push([max, Effort.Max]);
145
183
  const high = lower.search(/\bhigh\b/);
146
184
  if (high >= 0) candidates.push([high, Effort.High]);
147
185
  const medium = lower.search(/\bmed(?:ium)?\b/);
package/src/cli/args.ts CHANGED
@@ -44,6 +44,8 @@ export interface Args {
44
44
  advisor?: boolean;
45
45
  continue?: boolean;
46
46
  resume?: string | true;
47
+ fromClaude?: boolean;
48
+ fromCodex?: boolean;
47
49
  help?: boolean;
48
50
  version?: boolean;
49
51
  mode?: Mode;
@@ -227,6 +229,10 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
227
229
  result.alias = arg.slice("--alias=".length);
228
230
  } else if (arg === "--continue" || arg === "-c") {
229
231
  result.continue = true;
232
+ } else if (arg === "--from-claude") {
233
+ result.fromClaude = true;
234
+ } else if (arg === "--from-codex") {
235
+ result.fromCodex = true;
230
236
  } else if (arg === "--no-session") {
231
237
  result.noSession = true;
232
238
  } else if (arg === "--no-tools") {
@@ -290,6 +290,8 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
290
290
  "--version",
291
291
  "--allow-home",
292
292
  "--continue",
293
+ "--from-claude",
294
+ "--from-codex",
293
295
  "--no-session",
294
296
  "--no-tools",
295
297
  "--no-lsp",
@@ -1,5 +1,6 @@
1
1
  // biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
2
2
  // Gallery fixtures for the filesystem tools (read, write, glob).
3
+ import type { Usage } from "@oh-my-pi/pi-ai";
3
4
  import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
4
5
  import type { GalleryFixture, GalleryFixtureState, GalleryResult } from "./types";
5
6
 
@@ -47,6 +48,15 @@ const groupedReadDelimitedPath = groupedReadTargets.join(",");
47
48
  const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts";
48
49
  const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`;
49
50
 
51
+ const GROUPED_READ_USAGE: Usage = {
52
+ input: 2400,
53
+ output: 113,
54
+ cacheRead: 103_000,
55
+ cacheWrite: 0,
56
+ totalTokens: 105_513,
57
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
58
+ };
59
+
50
60
  function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult {
51
61
  return { content: [{ type: "text", text }], details, isError };
52
62
  }
@@ -81,6 +91,13 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number,
81
91
  false,
82
92
  "read-delimited",
83
93
  );
94
+ component.attachUsage(
95
+ ["read-delimited"],
96
+ GROUPED_READ_USAGE,
97
+ 5300,
98
+ 2200,
99
+ new Date(2026, 6, 28, 21, 5, 47).getTime(),
100
+ );
84
101
 
85
102
  if (state === "error") {
86
103
  component.updateResult(
@@ -88,10 +105,18 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number,
88
105
  false,
89
106
  "read-ranges",
90
107
  );
108
+ component.attachUsage(
109
+ ["read-ranges"],
110
+ GROUPED_READ_USAGE,
111
+ 4700,
112
+ 1900,
113
+ new Date(2026, 6, 28, 21, 5, 52).getTime(),
114
+ );
91
115
  return component.render(width);
92
116
  }
93
117
 
94
118
  component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges");
119
+ component.attachUsage(["read-ranges"], GROUPED_READ_USAGE, 4700, 1900, new Date(2026, 6, 28, 21, 5, 52).getTime());
95
120
  return component.render(width);
96
121
  }
97
122