@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
@@ -117,6 +117,7 @@ export interface TurnRecoveryHost {
117
117
  scheduleAgentContinue(options: { delayMs?: number; generation?: number; onError?: (error: unknown) => void }): void;
118
118
  waitForSessionMessagePersistence(message: AssistantMessage): Promise<void>;
119
119
  appendSessionMessage(message: AssistantMessage): void;
120
+ persistedAssistantEntryId(message: AssistantMessage): string | undefined;
120
121
  sessionMessageAlreadyPersisted(message: AssistantMessage): boolean;
121
122
  setModelWithProviderSessionReset(model: Model): Promise<void>;
122
123
  resetCurrentResponsesProviderSession(reason: string): void;
@@ -151,6 +152,12 @@ type PendingRecoveredRetryError = {
151
152
  note: string;
152
153
  };
153
154
 
155
+ type UsageLimitOutcome = {
156
+ switchedCredential: boolean;
157
+ retryAfterMs: number;
158
+ retryAtMs: number | undefined;
159
+ };
160
+
154
161
  /** Owns terminal-stop recovery, automatic retries, and fallback routing. */
155
162
  export class TurnRecovery {
156
163
  readonly #host: TurnRecoveryHost;
@@ -160,6 +167,7 @@ export class TurnRecovery {
160
167
  #retryResolve: (() => void) | undefined;
161
168
  #activeRetryFallback: ActiveRetryFallbackState | undefined;
162
169
  #pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = [];
170
+ #usageLimitOutcomes = new WeakMap<AssistantMessage, Promise<UsageLimitOutcome>>();
163
171
  #emptyStopRetryCount = 0;
164
172
  #unexpectedStopRetryCount = 0;
165
173
  #acceptTerminalEmptyStopForPrompt = false;
@@ -297,6 +305,40 @@ export class TurnRecovery {
297
305
  return this.#handleRetryableError(message, options);
298
306
  }
299
307
 
308
+ /**
309
+ * Records a usage-limit failure before replay eligibility decides whether the
310
+ * failed turn may be discarded. Returns whether credential recovery switched
311
+ * the active account.
312
+ */
313
+ async recordUsageLimitOutcome(message: AssistantMessage): Promise<boolean> {
314
+ if (message.stopReason !== "error") return false;
315
+ const id = this.#classifyRetryMessage(message);
316
+ const activeModel = this.#host.model();
317
+ if (!activeModel || !AIError.is(id, AIError.Flag.UsageLimit)) return false;
318
+
319
+ let recorded = this.#usageLimitOutcomes.get(message);
320
+ if (!recorded) {
321
+ const errorMessage = message.errorMessage || "Unknown error";
322
+ const retryAfterMs =
323
+ this.#parseRetryAfterMsFromError(errorMessage) ??
324
+ calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
325
+ recorded = (async (): Promise<UsageLimitOutcome> => {
326
+ const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
327
+ activeModel.provider,
328
+ this.#host.sessionId(),
329
+ { retryAfterMs, baseUrl: activeModel.baseUrl, modelId: activeModel.id },
330
+ );
331
+ return {
332
+ switchedCredential: outcome.switched,
333
+ retryAfterMs,
334
+ retryAtMs: outcome.retryAtMs,
335
+ };
336
+ })();
337
+ this.#usageLimitOutcomes.set(message, recorded);
338
+ }
339
+ return (await recorded).switchedCredential;
340
+ }
341
+
300
342
  /** Prompts after transient overlap with a prior agent run. */
301
343
  promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
302
344
  return this.#promptAgentWithIdleRetry(messages, options);
@@ -729,16 +771,24 @@ export class TurnRecovery {
729
771
  discardAssistantTurn(assistantMessage: AssistantMessage): void {
730
772
  this.removeAssistantMessageFromActiveContext(assistantMessage);
731
773
 
732
- const branchEntry = this.#host.sessionManager
733
- .getBranch()
734
- .slice()
735
- .reverse()
736
- .find(
737
- entry =>
738
- entry.type === "message" &&
739
- entry.message.role === "assistant" &&
740
- this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
741
- );
774
+ const branch = this.#host.sessionManager.getBranch();
775
+ const persistedEntryId = this.#host.persistedAssistantEntryId(assistantMessage);
776
+ const branchEntry =
777
+ (persistedEntryId === undefined
778
+ ? undefined
779
+ : branch.find(
780
+ entry =>
781
+ entry.id === persistedEntryId && entry.type === "message" && entry.message.role === "assistant",
782
+ )) ??
783
+ branch
784
+ .slice()
785
+ .reverse()
786
+ .find(
787
+ entry =>
788
+ entry.type === "message" &&
789
+ entry.message.role === "assistant" &&
790
+ this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
791
+ );
742
792
  if (!branchEntry) {
743
793
  return;
744
794
  }
@@ -757,7 +807,9 @@ export class TurnRecovery {
757
807
  (left.timestamp === right.timestamp &&
758
808
  left.provider === right.provider &&
759
809
  left.model === right.model &&
760
- left.stopReason === right.stopReason)
810
+ left.stopReason === right.stopReason &&
811
+ left.errorMessage === right.errorMessage &&
812
+ Bun.hash(JSON.stringify(left.content)) === Bun.hash(JSON.stringify(right.content)))
761
813
  );
762
814
  }
763
815
 
@@ -831,8 +883,13 @@ export class TurnRecovery {
831
883
  const contextWindow = this.#host.model()?.contextWindow ?? 0;
832
884
  if (AIError.isContextOverflow(message, contextWindow)) return false;
833
885
 
886
+ // A classifier refusal/sensitivity stop is the model's decision, not a route
887
+ // failure, but only after we confirm no user-visible output has already been
888
+ // streamed. Visible text, images, tool calls, or server tools must not be
889
+ // discarded and replayed.
890
+ if (this.#hasReplayUnsafeOutput(message)) return false;
834
891
  if (this.isClassifierRefusal(message)) return true;
835
- return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
892
+ return AIError.retriable(id);
836
893
  }
837
894
 
838
895
  /**
@@ -900,12 +957,22 @@ export class TurnRecovery {
900
957
  }
901
958
  /**
902
959
  * Retried turns remove the failed assistant message from active context.
903
- * Text/thinking-only partials are safe to discard and replay. Retained
904
- * tool calls are not: a completed tool call may already have emitted its
905
- * tool result after this assistant message, so replaying can duplicate work.
960
+ * Thinking-only partials are safe to discard and replay: reasoning models
961
+ * routinely stall after long thinking with no visible output, and duplicated
962
+ * thinking display is materially lower harm than duplicated final text.
963
+ * Whitespace-only text is likewise safe since nothing meaningful reached the
964
+ * user. Visible text, generated images, server tools, and retained tool calls
965
+ * are NOT safe: each has already rendered or may have side effects, so replaying
966
+ * the turn can duplicate user-visible output or work.
906
967
  */
907
- #hasReplayUnsafeToolOutput(message: AssistantMessage): boolean {
908
- return message.content.some(block => block.type === "toolCall");
968
+ #hasReplayUnsafeOutput(message: AssistantMessage): boolean {
969
+ return message.content.some(
970
+ block =>
971
+ block.type === "toolCall" ||
972
+ block.type === "image" ||
973
+ block.type === "anthropicServerTool" ||
974
+ (block.type === "text" && block.text.trim().length > 0),
975
+ );
909
976
  }
910
977
 
911
978
  /**
@@ -1087,14 +1154,14 @@ export class TurnRecovery {
1087
1154
  * transient overload/5xx or a hard "router/model not found / unsupported" —
1088
1155
  * is worth retrying on the base id. Skips failures the base model shares:
1089
1156
  * context overflow (compaction's job), usage limits and auth errors (same
1090
- * account/key), and turns that already emitted a tool call (replaying would
1091
- * duplicate work). Requires the base model to exist in the registry.
1157
+ * account/key), and turns that already emitted any replay-unsafe output.
1158
+ * Requires the base model to exist in the registry.
1092
1159
  */
1093
1160
  isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
1094
1161
  const model = this.#activeFireworksFastModel();
1095
1162
  if (!model) return false;
1096
1163
  if (message.stopReason !== "error") return false;
1097
- if (message.content.some(block => block.type === "toolCall")) return false;
1164
+ if (this.#hasReplayUnsafeOutput(message)) return false;
1098
1165
  // A content refusal/sensitivity stop is the model's decision, not a route
1099
1166
  // failure — switching to the base model would just re-trigger it.
1100
1167
  if (this.isClassifierRefusal(message)) return false;
@@ -1113,8 +1180,7 @@ export class TurnRecovery {
1113
1180
  * model switch cannot fix or must not replay: cancellations (abort-flavored
1114
1181
  * errors are not model faults), context overflow (compaction's job),
1115
1182
  * classifier refusals (chain consult is handled on the retryable path with
1116
- * `pinFallback`), and turns that already emitted a tool call (replaying
1117
- * could duplicate work).
1183
+ * `pinFallback`), and turns that already emitted replay-unsafe output.
1118
1184
  */
1119
1185
  isHardErrorFallbackEligible(message: AssistantMessage): boolean {
1120
1186
  if (message.stopReason !== "error") return false;
@@ -1126,7 +1192,7 @@ export class TurnRecovery {
1126
1192
  const id = this.#classifyRetryMessage(message);
1127
1193
  if (AIError.is(id, AIError.Flag.Abort) || AIError.is(id, AIError.Flag.UserInterrupt)) return false;
1128
1194
  if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
1129
- if (this.#hasReplayUnsafeToolOutput(message)) return false;
1195
+ if (this.#hasReplayUnsafeOutput(message)) return false;
1130
1196
  const currentSelector = formatRetryFallbackSelector(model, this.#host.thinkingLevel());
1131
1197
  const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector);
1132
1198
  if (!role) return false;
@@ -1308,6 +1374,7 @@ export class TurnRecovery {
1308
1374
  const errorMessage = message.errorMessage || "Unknown error";
1309
1375
  const id = this.#classifyRetryMessage(message);
1310
1376
  const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
1377
+ const recordedUsageLimitOutcome = await this.#usageLimitOutcomes.get(message);
1311
1378
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
1312
1379
  let delayMs = staleOpenAIResponsesReplayError
1313
1380
  ? 0
@@ -1322,31 +1389,8 @@ export class TurnRecovery {
1322
1389
  this.#host.resetCurrentResponsesProviderSession("stale replay error");
1323
1390
  }
1324
1391
 
1325
- const activeModel = this.#host.model();
1326
- if (
1327
- !retryBudgetExhausted &&
1328
- activeModel &&
1329
- !staleOpenAIResponsesReplayError &&
1330
- AIError.is(id, AIError.Flag.UsageLimit)
1331
- ) {
1332
- const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
1333
- const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
1334
- activeModel.provider,
1335
- this.#host.sessionId(),
1336
- {
1337
- retryAfterMs,
1338
- baseUrl: activeModel.baseUrl,
1339
- modelId: activeModel.id,
1340
- },
1341
- );
1342
- if (outcome.switched) {
1343
- switchedCredential = true;
1344
- delayMs = 0;
1345
- } else if (await this.#host.maybeAutoRedeemCodexReset()) {
1346
- // A live usage-limit 429 on the active Codex account, with a banked
1347
- // reset and the opt-in setting on: spend the reset and retry
1348
- // immediately instead of waiting out the window. Runs after the
1349
- // free sibling-switch above and before model fallback below.
1392
+ if (!retryBudgetExhausted && !staleOpenAIResponsesReplayError && recordedUsageLimitOutcome) {
1393
+ if (recordedUsageLimitOutcome.switchedCredential || (await this.#host.maybeAutoRedeemCodexReset())) {
1350
1394
  switchedCredential = true;
1351
1395
  delayMs = 0;
1352
1396
  } else {
@@ -1358,9 +1402,10 @@ export class TurnRecovery {
1358
1402
  // Without this, one short-lived sibling block escalates a
1359
1403
  // recoverable situation into the provider's multi-hour wait and
1360
1404
  // trips the fail-fast cap below.
1361
- usageLimitWaitMs = retryAfterMs;
1362
- if (outcome.retryAtMs !== undefined) {
1363
- const siblingWaitMs = Math.max(0, outcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS;
1405
+ usageLimitWaitMs = recordedUsageLimitOutcome.retryAfterMs;
1406
+ if (recordedUsageLimitOutcome.retryAtMs !== undefined) {
1407
+ const siblingWaitMs =
1408
+ Math.max(0, recordedUsageLimitOutcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS;
1364
1409
  if (siblingWaitMs < usageLimitWaitMs) {
1365
1410
  usageLimitWaitMs = siblingWaitMs;
1366
1411
  }
@@ -1398,6 +1443,7 @@ export class TurnRecovery {
1398
1443
  delayMs = parsedRetryAfterMs;
1399
1444
  }
1400
1445
  }
1446
+
1401
1447
  if (retryBudgetExhausted) {
1402
1448
  if (!switchedModel) {
1403
1449
  await this.persistTerminalEmptyErrorTurn(message);
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
5
  import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
6
- import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
6
+ import { APP_NAME, getMCPConfigPath, getProjectDir, logger, setProjectDir } from "@oh-my-pi/pi-utils";
7
7
  import { reset as resetCapabilities } from "../capability";
8
8
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
9
9
  import { CollabHost } from "../collab/host";
@@ -31,8 +31,10 @@ import {
31
31
  getPluginsCacheDir,
32
32
  MarketplaceManager,
33
33
  } from "../extensibility/plugins/marketplace";
34
+ import { readMCPConfigFile } from "../mcp/config-writer";
34
35
  import { resolveMemoryBackend } from "../memory-backend";
35
36
  import { runPauseScreen } from "../modes/components/pause-screen";
37
+ import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller";
36
38
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
37
39
  import { theme } from "../modes/theme/theme";
38
40
  import type { InteractiveModeContext } from "../modes/types";
@@ -1790,11 +1792,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1790
1792
  {
1791
1793
  name: "resume",
1792
1794
  description: "Resume a different session",
1793
- inlineHint: "[session id]",
1795
+ inlineHint: "[session id|@claude|@codex]",
1794
1796
  allowArgs: true,
1795
1797
  handleTui: async (command, runtime) => {
1796
1798
  const sessionArg = command.args.trim();
1797
1799
  runtime.ctx.editor.setText("");
1800
+ const foreignSource = sessionArg === "@claude" ? "claude" : sessionArg === "@codex" ? "codex" : undefined;
1801
+ if (foreignSource) {
1802
+ runtime.ctx.showSessionSelector(foreignSource);
1803
+ return;
1804
+ }
1798
1805
  if (!sessionArg) {
1799
1806
  runtime.ctx.showSessionSelector();
1800
1807
  return;
@@ -2714,6 +2721,124 @@ function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string
2714
2721
  };
2715
2722
  }
2716
2723
 
2724
+ /** /mcp subcommands whose argument is a server name (per their `usage: "<name>..."`). */
2725
+ const MCP_SERVER_NAME_SUBCOMMANDS: Readonly<Record<string, true>> = {
2726
+ enable: true,
2727
+ disable: true,
2728
+ test: true,
2729
+ remove: true,
2730
+ reconnect: true,
2731
+ reauth: true,
2732
+ unauth: true,
2733
+ };
2734
+
2735
+ /** Subcommands that accept names found only in `userConfig.disabledServers`. */
2736
+ const MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = {
2737
+ enable: true,
2738
+ disable: true,
2739
+ };
2740
+
2741
+ /**
2742
+ * Subcommands that accept configured servers whose `enabled` flag is false.
2743
+ * `unauth` can clear persisted credentials without connecting; test,
2744
+ * reconnect, and reauth explicitly require an enabled server.
2745
+ */
2746
+ const MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS: Readonly<Record<string, true>> = {
2747
+ enable: true,
2748
+ disable: true,
2749
+ unauth: true,
2750
+ };
2751
+
2752
+ /**
2753
+ * Build getArgumentCompletions for /mcp. Delegates to the generic
2754
+ * declarative subcommand completer while the subcommand name itself is
2755
+ * still being typed, then switches to MCP server-name completion (sourced
2756
+ * from {@link collectMcpServerNames}) once a recognized server-name
2757
+ * subcommand (enable/disable/test/remove/reconnect/reauth/unauth) is
2758
+ * followed by a space. `remove` gets its own scope-aware completions (see
2759
+ * {@link buildMcpRemoveCompletions}) since — unlike the others —
2760
+ * it only ever succeeds against a config-file entry. Subcommands with a
2761
+ * different argument shape (add, smithery-search, ...) get no argument
2762
+ * completion.
2763
+ */
2764
+ function buildMcpArgumentCompletions(
2765
+ subcommands: SubcommandDef[],
2766
+ runtime: TuiSlashCommandRuntime,
2767
+ ): (argumentPrefix: string) => Promise<AutocompleteItem[] | null> {
2768
+ const genericCompletions = buildArgumentCompletions(subcommands);
2769
+ return async (argumentPrefix: string) => {
2770
+ const spaceIndex = argumentPrefix.indexOf(" ");
2771
+ if (spaceIndex === -1) return genericCompletions(argumentPrefix);
2772
+
2773
+ const rawSubcommand = argumentPrefix.slice(0, spaceIndex);
2774
+ const lowerSubcommand = rawSubcommand.toLowerCase();
2775
+ if (MCP_SERVER_NAME_SUBCOMMANDS[lowerSubcommand] !== true) return null;
2776
+ const namePrefix = argumentPrefix.slice(spaceIndex + 1).toLowerCase();
2777
+ if (lowerSubcommand === "remove") {
2778
+ return await buildMcpRemoveCompletions(rawSubcommand, namePrefix);
2779
+ }
2780
+
2781
+ let serverNames: string[];
2782
+ try {
2783
+ serverNames = await collectMcpServerNames(
2784
+ runtime.ctx,
2785
+ undefined,
2786
+ MCP_DISABLED_ONLY_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true,
2787
+ MCP_DISABLED_CONFIG_ELIGIBLE_SUBCOMMANDS[lowerSubcommand] === true,
2788
+ );
2789
+ } catch (error) {
2790
+ logger.warn("MCP server-name autocomplete failed to read config", { error });
2791
+ return null;
2792
+ }
2793
+ const matches: AutocompleteItem[] = serverNames
2794
+ .filter(name => name.toLowerCase().startsWith(namePrefix))
2795
+ .map(name => ({ value: `${rawSubcommand} ${name} `, label: name }));
2796
+ return matches.length > 0 ? matches : null;
2797
+ };
2798
+ }
2799
+
2800
+ /**
2801
+ * Build `/mcp remove <name>` completions. Unlike the other server-name
2802
+ * subcommands, `#handleRemove` only ever succeeds against a config-file
2803
+ * `mcpServers` entry in the target scope (project by default, user with an
2804
+ * explicit `--scope user`) — a purely runtime-discovered server has no
2805
+ * config entry to remove and always fails with `Server "<name>" not found
2806
+ * in <scope> config.`. Completions are therefore restricted to config-file
2807
+ * names, and a name that exists only in the user config is completed with
2808
+ * `--scope user` appended so the inserted command is directly executable.
2809
+ */
2810
+ async function buildMcpRemoveCompletions(
2811
+ rawSubcommand: string,
2812
+ namePrefix: string,
2813
+ ): Promise<AutocompleteItem[] | null> {
2814
+ const cwd = getProjectDir();
2815
+ let projectNames: string[];
2816
+ let userNames: string[];
2817
+ try {
2818
+ const [projectConfig, userConfig] = await Promise.all([
2819
+ readMCPConfigFile(getMCPConfigPath("project", cwd)),
2820
+ readMCPConfigFile(getMCPConfigPath("user", cwd)),
2821
+ ]);
2822
+ projectNames = Object.keys(projectConfig.mcpServers ?? {});
2823
+ userNames = Object.keys(userConfig.mcpServers ?? {});
2824
+ } catch (error) {
2825
+ logger.warn("MCP remove autocomplete failed to read config", { error });
2826
+ return null;
2827
+ }
2828
+
2829
+ const projectNameSet = new Set(projectNames);
2830
+ const allNames = new Set([...projectNames, ...userNames]);
2831
+ const matches: AutocompleteItem[] = [...allNames]
2832
+ .filter(name => name.toLowerCase().startsWith(namePrefix))
2833
+ .map(name =>
2834
+ projectNameSet.has(name)
2835
+ ? { value: `${rawSubcommand} ${name} `, label: name }
2836
+ : { value: `${rawSubcommand} ${name} --scope user `, label: `${name} (user)` },
2837
+ )
2838
+ .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
2839
+ return matches.length > 0 ? matches : null;
2840
+ }
2841
+
2717
2842
  /**
2718
2843
  * Build getInlineHint from declarative subcommand definitions.
2719
2844
  * Shows remaining completion + usage as dim ghost text after cursor.
@@ -2877,7 +3002,10 @@ function materializeTuiBuiltinSlashCommand(
2877
3002
  ): TuiBuiltinSlashCommand {
2878
3003
  const materialized: TuiBuiltinSlashCommand = { ...cmd };
2879
3004
  if (cmd.subcommands) {
2880
- materialized.getArgumentCompletions = buildArgumentCompletions(cmd.subcommands);
3005
+ materialized.getArgumentCompletions =
3006
+ cmd.name === "mcp" && runtime
3007
+ ? buildMcpArgumentCompletions(cmd.subcommands, runtime)
3008
+ : buildArgumentCompletions(cmd.subcommands);
2881
3009
  materialized.getInlineHint = buildSubcommandInlineHint(cmd.subcommands);
2882
3010
  } else if (cmd.name === "move") {
2883
3011
  materialized.getArgumentCompletions = buildDirectoryArgumentCompletions();
@@ -540,8 +540,8 @@ export interface BuildSystemPromptOptions {
540
540
  renderMermaid?: boolean;
541
541
  /** Pre-resolved nested active repo context. Undefined resolves from cwd. */
542
542
  activeRepoContext?: ActiveRepoContext | null;
543
- /** Tools mounted under `xd://`; renders the protocol section when non-empty. */
544
- xdevTools?: Array<{ name: string; summary: string }>;
543
+ /** Tools mounted under `xd://`; renders the protocol section when non-empty. `dynamic` marks external devices whose summary is third-party metadata. */
544
+ xdevTools?: Array<{ name: string; summary: string; dynamic?: boolean }>;
545
545
  /** Full docs + JSON schema for every `xd://`-mounted tool, inlined into the protocol section so no discovery `read` is needed. */
546
546
  xdevDocs?: string;
547
547
  /** Whether Auto-QA grievance reporting is enabled; renders the `xd://report_issue` note. */
@@ -870,6 +870,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
870
870
  includeWorkspaceTree,
871
871
  renderMermaid,
872
872
  xdevTools,
873
+ hasDynamicXdevTools: xdevTools.some(mounted => mounted.dynamic === true),
873
874
  xdevDocs,
874
875
  autoQaEnabled,
875
876
  };
@@ -87,8 +87,10 @@ const MCP_CALL_TIMEOUT_MS = 60_000;
87
87
  * agent is driven to one forced final `yield` so partial findings come back
88
88
  * as a real report; only if it still refuses to yield within
89
89
  * {@link BUDGET_STOP_GRACE_REQUESTS} more requests is the run hard-aborted.
90
- * The `default` key applies to agents without an explicit entry and can be
91
- * overridden via the `task.softRequestBudget` setting (0 disables the guard).
90
+ * Entries are ceilings, not fixed values: the `default` key applies to agents
91
+ * without an explicit entry, and the `task.softRequestBudget` setting can only
92
+ * lower an agent's budget, never raise it above its bundled entry (0 disables
93
+ * the guard entirely).
92
94
  */
93
95
  export const SOFT_REQUEST_BUDGET: Record<string, number> = {
94
96
  scout: 100,
@@ -96,6 +98,18 @@ export const SOFT_REQUEST_BUDGET: Record<string, number> = {
96
98
  default: 200,
97
99
  };
98
100
 
101
+ /**
102
+ * Resolves the effective soft request budget for an agent. The configured
103
+ * `task.softRequestBudget` and the agent's bundled entry are both upper
104
+ * bounds, so the tighter one wins; a configured budget of 0 disables the
105
+ * guard regardless of the bundled entry.
106
+ */
107
+ export function resolveSoftRequestBudget(agentName: string, configuredBudget: number): number {
108
+ const normalized = Math.max(0, Math.trunc(configuredBudget));
109
+ if (normalized === 0) return 0;
110
+ return Math.min(normalized, SOFT_REQUEST_BUDGET[agentName] ?? normalized);
111
+ }
112
+
99
113
  /** Extra requests allowed after a budget stop for the forced yield to land before the run is hard-aborted. */
100
114
  export const BUDGET_STOP_GRACE_REQUESTS = 5;
101
115
 
@@ -2446,8 +2460,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2446
2460
  0,
2447
2461
  Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
2448
2462
  );
2449
- const softRequestBudget =
2450
- configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
2463
+ const softRequestBudget = resolveSoftRequestBudget(agent.name, configuredDefaultBudget);
2451
2464
  const softRequestBudgetNotice = settings.get("task.softRequestBudgetNotice") ?? false;
2452
2465
  const parentDepth = options.taskDepth ?? 0;
2453
2466
  const childDepth = parentDepth + 1;
@@ -49,6 +49,13 @@ interface TaskRenderContext {
49
49
  * commit-eligible rows do not repaint after entering native scrollback.
50
50
  */
51
51
  frozen?: boolean;
52
+ /**
53
+ * Wall clock for time-derived rows (current-tool elapsed, retry countdown).
54
+ * The component freezes it once the block settles or any of its rows enter
55
+ * native scrollback, so identical-input rebuilds stay byte-identical with
56
+ * committed history. Absent: render with the live clock.
57
+ */
58
+ nowMs?: number;
52
59
  }
53
60
  type TaskRenderOptions = RenderResultOptions & { renderContext?: TaskRenderContext };
54
61
 
@@ -888,6 +895,7 @@ function renderAgentProgress(
888
895
  frozen = false,
889
896
  seenNestedTasks?: WeakSet<object>,
890
897
  nestedDepth = 0,
898
+ nowMs = Date.now(),
891
899
  ): string[] {
892
900
  const lines: string[] = [];
893
901
 
@@ -964,7 +972,7 @@ function renderAgentProgress(
964
972
  toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
965
973
  }
966
974
  if (progress.currentToolStartMs) {
967
- const elapsed = Date.now() - progress.currentToolStartMs;
975
+ const elapsed = nowMs - progress.currentToolStartMs;
968
976
  if (elapsed > 5000) {
969
977
  toolLine += `${theme.sep.dot}${theme.fg("warning", formatDuration(elapsed))}`;
970
978
  }
@@ -986,7 +994,7 @@ function renderAgentProgress(
986
994
  // long until the next attempt. Without this, the parent UI would just
987
995
  // keep spinning while a child sleeps on a 3-hour provider rate-limit.
988
996
  if (progress.retryState && progress.status === "running") {
989
- const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
997
+ const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - nowMs);
990
998
  const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
991
999
  const summary =
992
1000
  `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
@@ -1079,6 +1087,7 @@ function renderAgentProgress(
1079
1087
  frozen,
1080
1088
  seenNestedTasks,
1081
1089
  nestedDepth,
1090
+ nowMs,
1082
1091
  );
1083
1092
  for (const line of nestedLines) {
1084
1093
  lines.push(`${continuePrefix}${line}`);
@@ -1560,6 +1569,7 @@ export function renderResult(
1560
1569
  return framedBlock(theme, width => {
1561
1570
  const { expanded, isPartial, spinnerFrame } = options;
1562
1571
  const frozen = options.renderContext?.frozen === true;
1572
+ const nowMs = options.renderContext?.nowMs ?? Date.now();
1563
1573
  const lines: string[] = [];
1564
1574
 
1565
1575
  // Result rows win once any exist; progress rows for spawns without a
@@ -1577,7 +1587,9 @@ export function renderResult(
1577
1587
  lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme));
1578
1588
  }
1579
1589
  for (const progress of visible) {
1580
- lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
1590
+ lines.push(
1591
+ ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
1592
+ );
1581
1593
  }
1582
1594
  } else if (details.results && details.results.length > 0) {
1583
1595
  const ordered = orderResultsForDisplay(details.results);
@@ -1602,7 +1614,9 @@ export function renderResult(
1602
1614
  )
1603
1615
  : [];
1604
1616
  for (const progress of supplementalProgress) {
1605
- lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
1617
+ lines.push(
1618
+ ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
1619
+ );
1606
1620
  }
1607
1621
 
1608
1622
  const summaryParts: string[] = [];
@@ -1742,6 +1756,7 @@ function renderNestedTaskTree(
1742
1756
  frozen = false,
1743
1757
  seen: WeakSet<object> = new WeakSet<object>(),
1744
1758
  depth = 0,
1759
+ nowMs = Date.now(),
1745
1760
  ): string[] {
1746
1761
  const lines: string[] = [];
1747
1762
  for (const details of detailsList) {
@@ -1788,6 +1803,7 @@ function renderNestedTaskTree(
1788
1803
  frozen,
1789
1804
  seen,
1790
1805
  depth + 1,
1806
+ nowMs,
1791
1807
  ),
1792
1808
  );
1793
1809
  });
package/src/thinking.ts CHANGED
@@ -191,7 +191,7 @@ export interface ConfiguredThinkingLevelMetadata {
191
191
  const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
192
192
  value: AUTO_THINKING,
193
193
  label: "auto",
194
- description: "Auto-detect per prompt (low–xhigh)",
194
+ description: "Auto-detect per prompt",
195
195
  };
196
196
 
197
197
  /**
@@ -236,6 +236,12 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
236
236
  * above Low (falling back to the full supported set only when the model maxes
237
237
  * out below Low). Within that pool the request snaps to the highest level not
238
238
  * exceeding it, or the pool minimum when the request is below the pool.
239
+ * `ceiling` bounds the pool from above, so a policy ceiling survives the model
240
+ * clamp: a sparse ladder such as `["max"]` must not snap an `xhigh` request up
241
+ * to `max`. The Low floor is resolved against the model's own ladder *before*
242
+ * the ceiling applies — a ceiling that hides every tier at or above Low means
243
+ * there is nothing legal to pick (`undefined`), not a licence to fall through
244
+ * to a sub-Low tier the model happens to expose.
239
245
  *
240
246
  * Returns `undefined` for reasoning-capable models without a controllable
241
247
  * effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
@@ -244,12 +250,19 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
244
250
  * forward a concrete effort that would then trip {@link requireSupportedEffort}
245
251
  * downstream.
246
252
  */
247
- export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
253
+ export function clampAutoThinkingEffort(
254
+ model: Model | undefined,
255
+ effort: Effort,
256
+ ceiling: Effort = Effort.Max,
257
+ ): Effort | undefined {
248
258
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
249
259
  if (supported.length === 0) return undefined;
250
260
  const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
251
- const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
252
- const pool = eligible.length > 0 ? eligible : supported;
261
+ const ceilingIndex = THINKING_EFFORTS.indexOf(ceiling);
262
+ const atOrAboveLow = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
263
+ const floored = atOrAboveLow.length > 0 ? atOrAboveLow : supported;
264
+ const pool = floored.filter(level => THINKING_EFFORTS.indexOf(level) <= ceilingIndex);
265
+ if (pool.length === 0) return undefined;
253
266
  const requestedIndex = THINKING_EFFORTS.indexOf(effort);
254
267
  let chosen = pool[0];
255
268
  for (const candidate of pool) {
@@ -351,14 +364,20 @@ export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boole
351
364
 
352
365
  /**
353
366
  * The provisional concrete level shown while `auto` is configured but before a
354
- * turn has been classified. Prefers the model's `defaultLevel`, otherwise High,
355
- * clamped into the auto range. Auto never provisions {@link Effort.Max} (the
356
- * classifier ceiling is XHigh; only an explicit user request reaches Max), so a
357
- * `defaultLevel` of `max` is capped at XHigh before clamping. Returns
358
- * `undefined` for non-reasoning models.
367
+ * turn has been classified, and the fallback when classification fails. Prefers
368
+ * the model's `defaultLevel`, otherwise High, clamped into the auto range.
369
+ *
370
+ * Deliberately stays below {@link Effort.Max}: the placeholder must not bill the
371
+ * top tier for a turn nobody classified, so XHigh is passed as a hard ceiling
372
+ * rather than only capping the preferred level — otherwise a sparse `["max"]`
373
+ * ladder would snap straight back up. A model whose ladder offers nothing at or
374
+ * below XHigh therefore has no provisional level, and `auto` leaves the current
375
+ * one in place. Classification itself may still resolve Max on models that
376
+ * expose the tier when the user opts in. Returns `undefined` for non-reasoning
377
+ * models.
359
378
  */
360
379
  export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined {
361
380
  if (!model?.reasoning) return undefined;
362
381
  const preferred = model.thinking?.defaultLevel ?? Effort.High;
363
- return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred);
382
+ return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred, Effort.XHigh);
364
383
  }