@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.2

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 (70) hide show
  1. package/CHANGELOG.md +140 -133
  2. package/dist/cli.js +250 -218
  3. package/dist/types/config/model-resolver.d.ts +14 -0
  4. package/dist/types/config/settings-schema.d.ts +22 -0
  5. package/dist/types/discovery/helpers.d.ts +7 -0
  6. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  7. package/dist/types/exec/non-interactive-env.d.ts +2 -0
  8. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  9. package/dist/types/modes/types.d.ts +5 -0
  10. package/dist/types/session/agent-session.d.ts +11 -1
  11. package/dist/types/session/messages.d.ts +3 -0
  12. package/dist/types/session/session-manager.d.ts +4 -1
  13. package/dist/types/task/index.d.ts +21 -0
  14. package/dist/types/tools/github-cache.d.ts +5 -4
  15. package/dist/types/tools/job.d.ts +1 -0
  16. package/dist/types/utils/markit.d.ts +8 -0
  17. package/dist/types/web/search/index.d.ts +2 -2
  18. package/dist/types/web/search/provider.d.ts +2 -0
  19. package/package.json +12 -12
  20. package/src/advisor/__tests__/advisor.test.ts +44 -0
  21. package/src/cli/args.ts +2 -0
  22. package/src/collab/host.ts +1 -1
  23. package/src/config/model-resolver.ts +35 -1
  24. package/src/config/settings-schema.ts +23 -1
  25. package/src/discovery/claude-plugins.ts +3 -42
  26. package/src/discovery/github.ts +189 -6
  27. package/src/discovery/helpers.ts +11 -0
  28. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  29. package/src/eval/js/shared/prelude.txt +12 -3
  30. package/src/eval/py/prelude.py +26 -2
  31. package/src/exec/bash-executor.ts +2 -2
  32. package/src/exec/non-interactive-env.ts +71 -0
  33. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  34. package/src/extensibility/extensions/runner.ts +17 -1
  35. package/src/extensibility/plugins/loader.ts +157 -23
  36. package/src/extensibility/plugins/manager.ts +44 -36
  37. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  38. package/src/extensibility/plugins/runtime-config.ts +9 -0
  39. package/src/internal-urls/docs-index.generated.ts +9 -9
  40. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  41. package/src/main.ts +5 -1
  42. package/src/modes/acp/acp-agent.ts +3 -3
  43. package/src/modes/components/settings-defs.ts +7 -0
  44. package/src/modes/components/tips.txt +1 -1
  45. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  46. package/src/modes/controllers/input-controller.ts +1 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +47 -0
  49. package/src/modes/rpc/rpc-mode.ts +3 -3
  50. package/src/modes/runtime-init.ts +2 -1
  51. package/src/modes/types.ts +5 -0
  52. package/src/prompts/agents/designer.md +8 -0
  53. package/src/prompts/review-request.md +1 -1
  54. package/src/prompts/system/subagent-system-prompt.md +4 -1
  55. package/src/prompts/tools/eval.md +13 -3
  56. package/src/prompts/tools/irc.md +1 -1
  57. package/src/sdk.ts +9 -1
  58. package/src/session/agent-session.ts +260 -50
  59. package/src/session/messages.ts +1 -1
  60. package/src/session/session-manager.ts +3 -1
  61. package/src/slash-commands/builtin-registry.ts +5 -2
  62. package/src/system-prompt.ts +7 -1
  63. package/src/task/executor.ts +105 -8
  64. package/src/task/index.ts +70 -9
  65. package/src/tools/github-cache.ts +32 -7
  66. package/src/tools/job.ts +14 -1
  67. package/src/utils/lang-from-path.ts +5 -0
  68. package/src/utils/markit.ts +24 -1
  69. package/src/web/search/index.ts +2 -2
  70. package/src/web/search/provider.ts +14 -2
@@ -32,6 +32,7 @@ import {
32
32
  type AsideMessage,
33
33
  type CompactionSummaryMessage,
34
34
  resolveTelemetry,
35
+ STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
35
36
  ThinkingLevel,
36
37
  } from "@oh-my-pi/pi-agent-core";
37
38
 
@@ -138,9 +139,11 @@ import {
138
139
  filterAvailableModelsByEnabledPatterns,
139
140
  formatModelSelectorValue,
140
141
  formatModelString,
142
+ formatModelStringWithRouting,
141
143
  getModelMatchPreferences,
142
144
  parseModelString,
143
145
  type ResolvedModelRoleValue,
146
+ resolveModelOverride,
144
147
  resolveModelRoleValue,
145
148
  resolveRoleSelection,
146
149
  } from "../config/model-resolver";
@@ -272,11 +275,13 @@ import {
272
275
  type BashExecutionMessage,
273
276
  type CustomMessage,
274
277
  convertToLlm,
278
+ GENERIC_ABORT_SENTINEL,
275
279
  type PythonExecutionMessage,
276
280
  readQueueChipText,
277
281
  SILENT_ABORT_MARKER,
278
282
  SKILL_PROMPT_MESSAGE_TYPE,
279
283
  stripImagesFromMessage,
284
+ USER_INTERRUPT_LABEL,
280
285
  } from "./messages";
281
286
  import type { SessionContext } from "./session-context";
282
287
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
@@ -496,6 +501,12 @@ export interface PromptOptions {
496
501
  toolChoice?: ToolChoice;
497
502
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
498
503
  synthetic?: boolean;
504
+ /** Marks this prompt as a deliberate user action (typed message, `.`/`c`
505
+ * continue). Clears advisor auto-resume suppression that a user interrupt set.
506
+ * Defaults to `!synthetic`; manual-continue is synthetic yet user-initiated, so
507
+ * it sets this explicitly. Agent-initiated synthetic prompts (auto-continue,
508
+ * plan re-prime, reminders) leave it unset and keep suppression latched. */
509
+ userInitiated?: boolean;
499
510
  /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
500
511
  attribution?: MessageAttribution;
501
512
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
@@ -632,8 +643,7 @@ function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | u
632
643
  }
633
644
 
634
645
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
635
- const selector = formatModelString(model);
636
- return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
646
+ return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
637
647
  }
638
648
 
639
649
  function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
@@ -941,6 +951,10 @@ function isDisplayableQueuedMessage(message: AgentMessage): boolean {
941
951
  return !(message.role === "custom" && message.display === false);
942
952
  }
943
953
 
954
+ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
955
+ return message.role === "custom" && message.customType === "advisor";
956
+ }
957
+
944
958
  function queueChipText(message: AgentMessage): string {
945
959
  if (message.role === "custom") {
946
960
  return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -988,10 +1002,15 @@ export class AgentSession {
988
1002
  #pendingNextTurnMessages: CustomMessage[] = [];
989
1003
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
990
1004
  #queuedMessageDrainScheduled = false;
1005
+ /** Latched true when the user deliberately interrupts (USER_INTERRUPT_LABEL);
1006
+ * suppresses advisor concern/blocker auto-resume until the user next resumes.
1007
+ * Advisor advice is still recorded into the transcript, just not auto-run. */
1008
+ #advisorAutoResumeSuppressed = false;
991
1009
  #planModeState: PlanModeState | undefined;
992
1010
  #goalModeState: GoalModeState | undefined;
993
1011
  #goalRuntime: GoalRuntime;
994
1012
  #advisorRuntime?: AdvisorRuntime;
1013
+ #advisorEnabled = false;
995
1014
  /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
996
1015
  #advisorAgent?: Agent;
997
1016
  #advisorReadOnlyTools?: AgentTool[];
@@ -1244,6 +1263,39 @@ export class AgentSession {
1244
1263
  this.#scheduleQueuedMessageDrain();
1245
1264
  }
1246
1265
 
1266
+ /** Remove advisor concern/blocker cards from the agent-core steer/follow-up
1267
+ * queues and return them. Used on a deliberate user interrupt so the post-abort
1268
+ * stranded-message drain cannot auto-resume the run on an advisor card that was
1269
+ * steered in just before the user stopped; real user follow-ups stay queued.
1270
+ * Synchronous and await-free so it runs before the abort path polls the queue. */
1271
+ #extractQueuedAdvisorCards(): CustomMessage[] {
1272
+ const steering = this.agent.peekSteeringQueue();
1273
+ const followUp = this.agent.peekFollowUpQueue();
1274
+ const cards = [...steering, ...followUp].filter(isAdvisorCard);
1275
+ if (cards.length === 0) return [];
1276
+ this.agent.replaceQueues(
1277
+ steering.filter(m => !isAdvisorCard(m)),
1278
+ followUp.filter(m => !isAdvisorCard(m)),
1279
+ );
1280
+ return cards;
1281
+ }
1282
+
1283
+ /** Record a suppressed advisor concern as visible, persisted advice without
1284
+ * triggering a turn. When the agent is idle (the normal post-interrupt case),
1285
+ * emit message_start/message_end like #flushPendingIrcAsides so
1286
+ * #handleAgentEvent renders it live (TUI/ACP) and persists it as a
1287
+ * CustomMessageEntry. While a turn is still tearing down (mid-abort), park it
1288
+ * hidden so abort's settle step replays it once idle — never appended into a
1289
+ * live streamMessage. */
1290
+ #preserveAdvisorCard(card: CustomMessage): void {
1291
+ if (this.isStreaming) {
1292
+ this.#pendingNextTurnMessages.push(card);
1293
+ return;
1294
+ }
1295
+ this.agent.emitExternalEvent({ type: "message_start", message: card });
1296
+ this.agent.emitExternalEvent({ type: "message_end", message: card });
1297
+ }
1298
+
1247
1299
  #resetInFlight(): void {
1248
1300
  this.#promptInFlightCount = 0;
1249
1301
  this.#releasePowerAssertion();
@@ -1443,7 +1495,8 @@ export class AgentSession {
1443
1495
  },
1444
1496
  });
1445
1497
 
1446
- if (this.settings.get("advisor.enabled")) this.#buildAdvisorRuntime();
1498
+ this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
1499
+ if (this.#advisorEnabled) this.#buildAdvisorRuntime();
1447
1500
 
1448
1501
  // Always subscribe to agent events for internal handling
1449
1502
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -1457,7 +1510,7 @@ export class AgentSession {
1457
1510
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1458
1511
  if (this.#isDisposed) return false;
1459
1512
  if (this.#advisorRuntime) return true;
1460
- if (!this.settings.get("advisor.enabled")) return false;
1513
+ if (!this.#advisorEnabled) return false;
1461
1514
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1462
1515
 
1463
1516
  const advisorSel = resolveRoleSelection(
@@ -1472,23 +1525,33 @@ export class AgentSession {
1472
1525
  }
1473
1526
 
1474
1527
  // Concern and blocker interrupt the running agent through the steering
1475
- // channel (aborting in-flight tools at the next steering boundary); when
1476
- // the loop has already yielded, triggerTurn resumes it so the advice is
1477
- // acted on immediately rather than waiting for the next user prompt. A
1478
- // plain nit rides the non-interrupting YieldQueue aside.
1528
+ // channel (aborting in-flight tools at the next steering boundary); when the
1529
+ // loop has already yielded, triggerTurn resumes it so the advice is acted on
1530
+ // immediately rather than waiting for the next user prompt. After a deliberate
1531
+ // user interrupt that auto-resume is suppressed: the concern is recorded as
1532
+ // visible advice and re-enters context only when the user resumes. A plain nit
1533
+ // rides the non-interrupting YieldQueue aside.
1479
1534
  const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1480
1535
  if (isInterruptingSeverity(severity)) {
1481
1536
  const notes: AdvisorNote[] = [{ note, severity }];
1482
- void this.sendCustomMessage(
1483
- {
1537
+ const content = formatAdvisorBatchContent(notes);
1538
+ const details = { notes } satisfies AdvisorMessageDetails;
1539
+ if (this.#advisorAutoResumeSuppressed) {
1540
+ this.#preserveAdvisorCard({
1541
+ role: "custom",
1484
1542
  customType: "advisor",
1485
- content: formatAdvisorBatchContent(notes),
1543
+ content,
1486
1544
  display: true,
1487
1545
  attribution: "agent",
1488
- details: { notes } satisfies AdvisorMessageDetails,
1489
- },
1546
+ details,
1547
+ timestamp: Date.now(),
1548
+ });
1549
+ return;
1550
+ }
1551
+ void this.sendCustomMessage(
1552
+ { customType: "advisor", content, display: true, attribution: "agent", details },
1490
1553
  { deliverAs: "steer", triggerTurn: true },
1491
- ).catch(err => logger.debug("advisor steer failed", { err: String(err) }));
1554
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1492
1555
  return;
1493
1556
  }
1494
1557
  this.yieldQueue.enqueue("advisor", { note, severity });
@@ -2341,6 +2404,11 @@ export class AgentSession {
2341
2404
  return;
2342
2405
  }
2343
2406
 
2407
+ if (this.#isRetryableReasonlessAbort(msg)) {
2408
+ const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
2409
+ if (didRetry) return;
2410
+ }
2411
+
2344
2412
  // A deliberate abort should settle the current turn, not trigger queued continuations.
2345
2413
  if (msg.stopReason === "aborted") {
2346
2414
  this.#resolveRetry();
@@ -2505,6 +2573,11 @@ export class AgentSession {
2505
2573
 
2506
2574
  #scheduleAutoContinuePrompt(generation: number): void {
2507
2575
  const continuePrompt = async () => {
2576
+ // Compaction summarizes away the first-message eager preludes, so re-assert the
2577
+ // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
2578
+ // at invocation (past the abort check below), so an aborted continuation queues
2579
+ // nothing; scoped to this request via prependMessages, never the shared queue.
2580
+ const eagerNudges = this.#buildPostCompactionEagerNudges();
2508
2581
  await this.#promptWithMessage(
2509
2582
  {
2510
2583
  role: "developer",
@@ -2513,7 +2586,10 @@ export class AgentSession {
2513
2586
  timestamp: Date.now(),
2514
2587
  },
2515
2588
  autoContinuePrompt,
2516
- { skipPostPromptRecoveryWait: true },
2589
+ {
2590
+ skipPostPromptRecoveryWait: true,
2591
+ prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined,
2592
+ },
2517
2593
  );
2518
2594
  };
2519
2595
  this.#schedulePostPromptTask(
@@ -5034,6 +5110,13 @@ export class AgentSession {
5034
5110
  // agent-initiated turns never trigger them.
5035
5111
  const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText);
5036
5112
 
5113
+ // A user-initiated prompt (typed message or the `.`/`c` continue shortcut)
5114
+ // re-enables advisor auto-resume that a prior user interrupt suppressed.
5115
+ // Agent-initiated synthetic prompts (auto-continue, plan, reminders) do not.
5116
+ if (options?.userInitiated ?? !options?.synthetic) {
5117
+ this.#advisorAutoResumeSuppressed = false;
5118
+ }
5119
+
5037
5120
  // If streaming, queue via steer() or followUp() based on option
5038
5121
  if (this.isStreaming) {
5039
5122
  if (!options?.streamingBehavior) {
@@ -5494,6 +5577,10 @@ export class AgentSession {
5494
5577
  images: ImageContent[] | undefined,
5495
5578
  mode: "steer" | "followUp",
5496
5579
  ): Promise<void> {
5580
+ // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
5581
+ // while streaming) is a deliberate resume; re-enable advisor auto-resume that
5582
+ // a user interrupt suppressed.
5583
+ this.#advisorAutoResumeSuppressed = false;
5497
5584
  const normalizedImages = await this.#normalizeImagesForModel(images);
5498
5585
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5499
5586
  if (normalizedImages?.length) {
@@ -5872,6 +5959,12 @@ export class AgentSession {
5872
5959
  * abort. Omit it for internal/lifecycle aborts.
5873
5960
  */
5874
5961
  async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
5962
+ const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
5963
+ if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
5964
+ // Pull advisor concerns out of the steer/follow-up queues before any await so
5965
+ // the post-abort stranded-message drain can't auto-resume the run on them.
5966
+ // They are re-recorded as visible advice once the agent settles (below).
5967
+ const strandedAdvisorCards = userInterrupt ? this.#extractQueuedAdvisorCards() : [];
5875
5968
  // Session switch/compact paths disconnect first; explicit aborts should
5876
5969
  // leave any queued steer/follow-up visible for the user rather than
5877
5970
  // auto-starting a fresh turn during cleanup.
@@ -5900,6 +5993,19 @@ export class AgentSession {
5900
5993
  if (this.#toolChoiceQueue.hasInFlight) {
5901
5994
  this.#toolChoiceQueue.reject("aborted");
5902
5995
  }
5996
+ // Re-record advisor concerns the interrupt would otherwise strand, as
5997
+ // visible/persisted advice without triggering a turn (the agent is idle
5998
+ // now): cards steered into the queue before the user stopped, plus any
5999
+ // that arrived via enqueueAdvice mid-abort and were parked hidden in
6000
+ // #pendingNextTurnMessages while the turn was still tearing down. Other
6001
+ // deferred next-turn context (non-advisor) stays queued, in order.
6002
+ const parkedAdvisorCards = this.#pendingNextTurnMessages.filter(isAdvisorCard);
6003
+ if (parkedAdvisorCards.length > 0) {
6004
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m));
6005
+ }
6006
+ for (const card of [...strandedAdvisorCards, ...parkedAdvisorCards]) {
6007
+ this.#preserveAdvisorCard(card);
6008
+ }
5903
6009
  } finally {
5904
6010
  this.#abortInProgress = false;
5905
6011
  this.#drainStrandedQueuedMessages();
@@ -7683,7 +7789,9 @@ export class AgentSession {
7683
7789
  };
7684
7790
  }
7685
7791
 
7686
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7792
+ #createEagerTodoPrelude(
7793
+ promptText: string | undefined,
7794
+ ): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7687
7795
  const mode = this.settings.get("todo.eager");
7688
7796
  const todosEnabled = this.settings.get("todo.enabled");
7689
7797
  if (mode === "default" || !todosEnabled) {
@@ -7700,14 +7808,18 @@ export class AgentSession {
7700
7808
  // Only inject on the first user message of the conversation. Subsequent user
7701
7809
  // turns must not receive the eager todo reminder — they often correct, clarify,
7702
7810
  // or redirect the prior task, and forcing a brand-new todo list there is wrong.
7703
- const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7704
- if (hasPriorUserMessage) {
7705
- return undefined;
7706
- }
7811
+ // When `promptText` is undefined (post-compaction re-injection) there is no fresh
7812
+ // user message to gate on, so skip the first-message and prompt-suffix checks.
7813
+ if (promptText !== undefined) {
7814
+ const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7815
+ if (hasPriorUserMessage) {
7816
+ return undefined;
7817
+ }
7707
7818
 
7708
- const trimmedPromptText = promptText.trimEnd();
7709
- if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7710
- return undefined;
7819
+ const trimmedPromptText = promptText.trimEnd();
7820
+ if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7821
+ return undefined;
7822
+ }
7711
7823
  }
7712
7824
 
7713
7825
  // Must check the active tool set, not just the registry: tool discovery
@@ -7730,8 +7842,10 @@ export class AgentSession {
7730
7842
  timestamp: Date.now(),
7731
7843
  };
7732
7844
  // `preferred` suggests a todo list (reminder only); `always` also forces the
7733
- // `todo` tool on the first turn — the previous boolean-on behavior.
7734
- if (mode === "preferred") {
7845
+ // `todo` tool on the first turn — the previous boolean-on behavior. Post-compaction
7846
+ // re-injection (`promptText === undefined`) is always reminder-only: forcing a tool
7847
+ // onto the auto-resumed turn would override the agent's in-flight action.
7848
+ if (promptText === undefined || mode === "preferred") {
7735
7849
  return { message };
7736
7850
  }
7737
7851
  const todoToolChoice = buildNamedToolChoice("todo", this.model);
@@ -7749,7 +7863,7 @@ export class AgentSession {
7749
7863
  return { message, toolChoice: todoToolChoice };
7750
7864
  }
7751
7865
 
7752
- #createEagerTaskPrelude(promptText: string): AgentMessage | undefined {
7866
+ #createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
7753
7867
  if (this.settings.get("task.eager") !== "always") return undefined;
7754
7868
  // Main agent only: subagents keep `task` active (the parent only filters `todo`),
7755
7869
  // so a salient delegate-reminder there would amplify nested fan-out. Gate on the
@@ -7757,9 +7871,13 @@ export class AgentSession {
7757
7871
  // still gets the reminder.
7758
7872
  if (this.#agentKind === "sub") return undefined;
7759
7873
  if (this.#planModeState?.enabled) return undefined;
7760
- if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
7761
- const trimmed = promptText.trimEnd();
7762
- if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
7874
+ // First-message-only gates are skipped post-compaction (`promptText === undefined`),
7875
+ // where there is no fresh user message to suppress the reminder for.
7876
+ if (promptText !== undefined) {
7877
+ if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
7878
+ const trimmed = promptText.trimEnd();
7879
+ if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
7880
+ }
7763
7881
  if (!this.getActiveToolNames().includes("task")) return undefined;
7764
7882
  return {
7765
7883
  role: "custom",
@@ -7770,6 +7888,24 @@ export class AgentSession {
7770
7888
  timestamp: Date.now(),
7771
7889
  };
7772
7890
  }
7891
+
7892
+ /**
7893
+ * Build the eager task/todo reminders to re-inject on the auto-continuation turn that
7894
+ * follows a compaction. The first-message preludes are the oldest messages, so
7895
+ * compaction summarizes them away and the agent silently loses the delegate-via-tasks
7896
+ * and phased-todo guidance mid-work; this re-asserts them, reminder-only (the todo
7897
+ * builder drops its forced tool_choice when `promptText` is undefined). Each builder
7898
+ * still applies its own mode / agent-kind / plan-mode / tool-active / surviving-todo
7899
+ * gates, so an empty array means nothing currently warrants a nudge.
7900
+ */
7901
+ #buildPostCompactionEagerNudges(): AgentMessage[] {
7902
+ const nudges: AgentMessage[] = [];
7903
+ const todo = this.#createEagerTodoPrelude(undefined);
7904
+ if (todo) nudges.push(todo.message);
7905
+ const task = this.#createEagerTaskPrelude(undefined);
7906
+ if (task) nudges.push(task);
7907
+ return nudges;
7908
+ }
7773
7909
  /**
7774
7910
  * Check if agent stopped with incomplete todos and prompt to continue.
7775
7911
  */
@@ -9045,9 +9181,31 @@ export class AgentSession {
9045
9181
  // Auto-Retry
9046
9182
  // =========================================================================
9047
9183
 
9184
+ /**
9185
+ * Retry an empty, reason-less provider abort: a turn that ended `aborted`
9186
+ * with no content and the generic sentinel (bare `abort()`), but only while
9187
+ * the session is neither aborting nor tearing down. A user/lifecycle abort
9188
+ * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
9189
+ * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
9190
+ * auto-generated-file guard or failed-patch preview) is deliberate and MUST
9191
+ * settle the turn instead: routing it through retry would orphan
9192
+ * `#retryPromise` on a continuation the guard skips (hanging the in-flight
9193
+ * `prompt()`) or silently undo the guard's intended abort.
9194
+ */
9195
+ #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
9196
+ return (
9197
+ message.stopReason === "aborted" &&
9198
+ message.content.length === 0 &&
9199
+ message.errorMessage === GENERIC_ABORT_SENTINEL &&
9200
+ !this.#abortInProgress &&
9201
+ !this.#isDisposed &&
9202
+ !this.#streamingEditAbortTriggered
9203
+ );
9204
+ }
9205
+
9048
9206
  /**
9049
9207
  * Check if an error is retryable (transient errors or usage limits).
9050
- * Context overflow errors are NOT retryable (handled by compaction instead).
9208
+ * Context overflow is NOT retryable (handled by compaction instead).
9051
9209
  * Usage-limit errors are retryable because the retry handler performs credential switching.
9052
9210
  */
9053
9211
  #isRetryableError(message: AssistantMessage): boolean {
@@ -9058,11 +9216,22 @@ export class AgentSession {
9058
9216
  if (isContextOverflow(message, contextWindow)) return false;
9059
9217
 
9060
9218
  if (this.#isClassifierRefusal(message)) return true;
9219
+ if (this.#streamInterruptedAfterObservableOutput(message)) return false;
9061
9220
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
9062
9221
 
9063
9222
  const err = message.errorMessage;
9064
9223
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
9065
9224
  }
9225
+ #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean {
9226
+ if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true;
9227
+ for (const block of message.content) {
9228
+ if (block.type === "toolCall") return true;
9229
+ if (block.type === "text" && block.text.length > 0) return true;
9230
+ if (block.type === "thinking" && block.thinking.length > 0) return true;
9231
+ if (block.type === "redactedThinking" && block.data.length > 0) return true;
9232
+ }
9233
+ return false;
9234
+ }
9066
9235
 
9067
9236
  #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
9068
9237
  const currentApi = this.model?.api;
@@ -9193,11 +9362,25 @@ export class AgentSession {
9193
9362
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9194
9363
  if (!parsedCurrent) return undefined;
9195
9364
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9365
+ const currentPlainSelector = this.model
9366
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9367
+ : undefined;
9368
+ const currentPlainBaseSelector =
9369
+ currentPlainSelector && currentPlainSelector !== currentSelector
9370
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9371
+ : undefined;
9372
+
9373
+ for (const role of Object.keys(this.#getRetryFallbackChains())) {
9374
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9375
+ if (primarySelector?.raw === currentSelector) return role;
9376
+ }
9196
9377
  for (const role of Object.keys(this.#getRetryFallbackChains())) {
9197
9378
  const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9198
9379
  if (!primarySelector) continue;
9199
- if (primarySelector.raw === currentSelector) return role;
9200
- if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
9380
+ if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
9381
+ const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
9382
+ if (primaryBaseSelector === currentBaseSelector) return role;
9383
+ if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
9201
9384
  }
9202
9385
  return undefined;
9203
9386
  }
@@ -9221,10 +9404,23 @@ export class AgentSession {
9221
9404
  if (chain.length <= 1) return [];
9222
9405
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9223
9406
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9224
- const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
9407
+ const currentPlainSelector =
9408
+ this.model && parsedCurrent
9409
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9410
+ : undefined;
9411
+ const currentPlainBaseSelector =
9412
+ parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
9413
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9414
+ : undefined;
9415
+ const exactIndex = chain.findIndex(
9416
+ selector => selector.raw === currentSelector || selector.raw === currentPlainSelector,
9417
+ );
9225
9418
  if (exactIndex >= 0) return chain.slice(exactIndex + 1);
9226
9419
  const baseIndex = currentBaseSelector
9227
- ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
9420
+ ? chain.findIndex(selector => {
9421
+ const selectorBase = formatRetryFallbackBaseSelector(selector);
9422
+ return selectorBase === currentBaseSelector || selectorBase === currentPlainBaseSelector;
9423
+ })
9228
9424
  : -1;
9229
9425
  if (baseIndex >= 0) return chain.slice(baseIndex + 1);
9230
9426
  return chain.slice(1);
@@ -9236,7 +9432,8 @@ export class AgentSession {
9236
9432
  currentSelector: string,
9237
9433
  options?: { pinFallback?: boolean },
9238
9434
  ): Promise<void> {
9239
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9435
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9436
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9240
9437
  if (!candidate) {
9241
9438
  throw new Error(`Retry fallback model not found: ${selector.raw}`);
9242
9439
  }
@@ -9249,10 +9446,10 @@ export class AgentSession {
9249
9446
  // `auto` instead of collapsing it to the level it resolved to this turn.
9250
9447
  const currentThinkingLevel = this.configuredThinkingLevel();
9251
9448
  const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
9252
-
9449
+ const candidateSelector = formatModelStringWithRouting(candidate);
9253
9450
  this.#setModelWithProviderSessionReset(candidate);
9254
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9255
- this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
9451
+ this.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9452
+ this.settings.getStorage()?.recordModelUsage(candidateSelector);
9256
9453
  this.setThinkingLevel(nextThinkingLevel);
9257
9454
  if (!this.#activeRetryFallback) {
9258
9455
  this.#activeRetryFallback = {
@@ -9280,7 +9477,8 @@ export class AgentSession {
9280
9477
 
9281
9478
  for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
9282
9479
  if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
9283
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9480
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9481
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9284
9482
  if (!candidate) continue;
9285
9483
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
9286
9484
  if (!apiKey) continue;
@@ -9318,7 +9516,9 @@ export class AgentSession {
9318
9516
  }
9319
9517
  if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
9320
9518
 
9321
- const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9519
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
9520
+ const primaryModel =
9521
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9322
9522
  if (!primaryModel) return;
9323
9523
  const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
9324
9524
  if (!apiKey) return;
@@ -9326,9 +9526,10 @@ export class AgentSession {
9326
9526
  const currentThinkingLevel = this.configuredThinkingLevel();
9327
9527
  const thinkingToApply =
9328
9528
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
9529
+ const primarySelector = formatModelStringWithRouting(primaryModel);
9329
9530
  this.#setModelWithProviderSessionReset(primaryModel);
9330
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9331
- this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
9531
+ this.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9532
+ this.settings.getStorage()?.recordModelUsage(primarySelector);
9332
9533
  this.setThinkingLevel(thinkingToApply);
9333
9534
  this.#clearActiveRetryFallback();
9334
9535
  }
@@ -9388,7 +9589,10 @@ export class AgentSession {
9388
9589
  * Handle retryable errors with exponential backoff.
9389
9590
  * @returns true if retry was initiated, false if max retries exceeded or disabled
9390
9591
  */
9391
- async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
9592
+ async #handleRetryableError(
9593
+ message: AssistantMessage,
9594
+ options?: { allowModelFallback?: boolean },
9595
+ ): Promise<boolean> {
9392
9596
  const retrySettings = this.settings.getGroup("retry");
9393
9597
  if (!retrySettings.enabled) return false;
9394
9598
  const classifierRefusal = this.#isClassifierRefusal(message);
@@ -9476,9 +9680,10 @@ export class AgentSession {
9476
9680
  }
9477
9681
  }
9478
9682
 
9683
+ const allowModelFallback = options?.allowModelFallback !== false;
9479
9684
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
9480
9685
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
9481
- if (retrySettings.modelFallback) {
9686
+ if (allowModelFallback && retrySettings.modelFallback) {
9482
9687
  if (!classifierRefusal) {
9483
9688
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
9484
9689
  }
@@ -11193,18 +11398,16 @@ export class AgentSession {
11193
11398
  }
11194
11399
 
11195
11400
  /**
11196
- * Enable or disable the advisor for this session. The setting is persisted,
11401
+ * Enable or disable the advisor for this session. The setting is overridden for the session,
11197
11402
  * and the runtime is started or stopped to match.
11198
11403
  *
11199
11404
  * @returns true when the advisor is actively running after the call.
11200
11405
  */
11201
11406
  setAdvisorEnabled(enabled: boolean): boolean {
11407
+ this.#advisorEnabled = enabled;
11202
11408
  if (enabled) {
11203
- this.settings.clearOverride("advisor.enabled");
11204
- this.settings.set("advisor.enabled", true);
11205
11409
  return this.#buildAdvisorRuntime(true);
11206
11410
  }
11207
- this.settings.set("advisor.enabled", false);
11208
11411
  this.#stopAdvisorRuntime();
11209
11412
  return false;
11210
11413
  }
@@ -11215,7 +11418,14 @@ export class AgentSession {
11215
11418
  * @returns true when the advisor is actively running after the call.
11216
11419
  */
11217
11420
  toggleAdvisorEnabled(): boolean {
11218
- return this.setAdvisorEnabled(!this.settings.get("advisor.enabled"));
11421
+ return this.setAdvisorEnabled(!this.#advisorEnabled);
11422
+ }
11423
+
11424
+ /**
11425
+ * Whether the advisor setting is enabled for this session.
11426
+ */
11427
+ isAdvisorEnabled(): boolean {
11428
+ return this.#advisorEnabled;
11219
11429
  }
11220
11430
 
11221
11431
  /**
@@ -11232,7 +11442,7 @@ export class AgentSession {
11232
11442
  * Return structured advisor stats for the status command and TUI panel.
11233
11443
  */
11234
11444
  getAdvisorStats(): AdvisorStats {
11235
- const configured = this.settings.get("advisor.enabled") as boolean;
11445
+ const configured = this.#advisorEnabled;
11236
11446
  const advisor = this.#advisorAgent;
11237
11447
  if (!advisor) {
11238
11448
  return {
@@ -94,7 +94,7 @@ export function shouldRenderAbortReason(errorMessage: string | undefined): boole
94
94
 
95
95
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
96
96
  * reason (bare `abort()`). Renderers treat it as "no specific reason given". */
97
- const GENERIC_ABORT_SENTINEL = "Request was aborted";
97
+ export const GENERIC_ABORT_SENTINEL = "Request was aborted";
98
98
 
99
99
  /** Resolve the operator-facing label for an aborted assistant turn. A custom
100
100
  * abort reason threaded onto `errorMessage` is returned verbatim; aborts with
@@ -1522,15 +1522,17 @@ export class SessionManager {
1522
1522
  /**
1523
1523
  * Open a specific session file.
1524
1524
  * @param sessionDir Optional dir for /new or /branch; defaults to the file's parent.
1525
+ * @param options.initialCwd Cwd to use when the file is empty or missing.
1525
1526
  */
1526
1527
  static async open(
1527
1528
  filePath: string,
1528
1529
  sessionDir?: string,
1529
1530
  storage: SessionStorage = new FileSessionStorage(),
1531
+ options?: { initialCwd?: string },
1530
1532
  ): Promise<SessionManager> {
1531
1533
  const loaded = await loadEntriesFromFile(filePath, storage);
1532
1534
  const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
1533
- const cwd = header?.cwd ?? getProjectDir();
1535
+ const cwd = header?.cwd ?? options?.initialCwd ?? getProjectDir();
1534
1536
  const dir = sessionDir ?? path.dirname(path.resolve(filePath));
1535
1537
  const manager = new SessionManager(cwd, dir, true, storage);
1536
1538
  await manager.setSessionFile(filePath);