@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.20

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.
@@ -534,6 +534,7 @@ export class InputController {
534
534
  setupEditorSubmitHandler(): void {
535
535
  this.ctx.editor.onSubmit = async (text: string) => {
536
536
  text = text.trim();
537
+ const hasPendingImages = this.ctx.editor.pendingImages.length > 0;
537
538
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
538
539
 
539
540
  // Focused subagent session: the editor is a plain chat box for it.
@@ -546,7 +547,7 @@ export class InputController {
546
547
 
547
548
  // Empty submit while streaming with queued messages: abort the active
548
549
  // turn and let the post-unwind drain deliver the agent-core queue.
549
- if (!text && this.ctx.session.isStreaming) {
550
+ if (!text && !hasPendingImages && this.ctx.session.isStreaming) {
550
551
  if (this.ctx.session.queuedMessageCount > 0) {
551
552
  const aborting = this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
552
553
  await aborting;
@@ -556,7 +557,7 @@ export class InputController {
556
557
  return;
557
558
  }
558
559
 
559
- if (!text) return;
560
+ if (!text && !hasPendingImages) return;
560
561
 
561
562
  // Continue shortcuts: "." or "c" resume the agent with a hidden agent-authored
562
563
  // developer directive (no visible user message) instead of an empty turn, so the
@@ -579,6 +580,7 @@ export class InputController {
579
580
  let inputImages = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
580
581
  let inputImageLinks =
581
582
  this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
583
+ let hasInputImages = (inputImages?.length ?? 0) > 0;
582
584
 
583
585
  if (runner?.hasHandlers("input")) {
584
586
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -596,24 +598,27 @@ export class InputController {
596
598
  this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
597
599
  );
598
600
  }
601
+ hasInputImages = (inputImages?.length ?? 0) > 0;
599
602
  }
600
603
 
601
- if (!text) return;
604
+ if (!text && !hasInputImages) return;
602
605
 
603
606
  // Handle built-in slash commands
604
- const slashResult = await executeBuiltinSlashCommand(text, {
605
- ctx: this.ctx,
606
- });
607
- if (slashResult === true) {
608
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
609
- return;
610
- }
611
- if (typeof slashResult === "string") {
612
- // Command handled but returned remaining text to use as prompt.
613
- // Record the original slash command text so Up Arrow recalls
614
- // "/loop 10 fix bug" rather than just "fix bug".
615
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
616
- text = slashResult;
607
+ if (text) {
608
+ const slashResult = await executeBuiltinSlashCommand(text, {
609
+ ctx: this.ctx,
610
+ });
611
+ if (slashResult === true) {
612
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
613
+ return;
614
+ }
615
+ if (typeof slashResult === "string") {
616
+ // Command handled but returned remaining text to use as prompt.
617
+ // Record the original slash command text so Up Arrow recalls
618
+ // "/loop 10 fix bug" rather than just "fix bug".
619
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
620
+ text = slashResult;
621
+ }
617
622
  }
618
623
 
619
624
  // Collab guest: prompts execute on the host; local slash/skill/bash/
@@ -647,7 +652,7 @@ export class InputController {
647
652
  // free-text Enter semantics applied a few lines below at the streaming
648
653
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
649
654
  // same helper with `"followUp"`.
650
- if (await this.#invokeSkillCommand(text, "steer")) {
655
+ if (text && (await this.#invokeSkillCommand(text, "steer"))) {
651
656
  return;
652
657
  }
653
658
 
@@ -714,11 +719,25 @@ export class InputController {
714
719
  // (a user-role `message_start` event) leaves any draft the user has
715
720
  // typed since queuing intact. Same protection as #783, applied to
716
721
  // the streaming/queue path.
717
- await this.ctx.withLocalSubmission(
718
- text,
719
- () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
720
- { imageCount: images?.length ?? 0 },
721
- );
722
+ try {
723
+ await this.ctx.withLocalSubmission(
724
+ text,
725
+ () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
726
+ { imageCount: images?.length ?? 0 },
727
+ );
728
+ } catch (error) {
729
+ // Don't lose the queued steer draft: restore text and images so
730
+ // the user can retry after dispatch validation/queue failures.
731
+ this.ctx.editor.setText(text);
732
+ if (images && images.length > 0) {
733
+ this.ctx.editor.pendingImages = [...images];
734
+ this.ctx.editor.pendingImageLinks = inputImageLinks
735
+ ? [...inputImageLinks]
736
+ : images.map(() => undefined);
737
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
738
+ }
739
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
740
+ }
722
741
  this.ctx.updatePendingMessagesDisplay();
723
742
  this.ctx.ui.requestRender();
724
743
  return;
@@ -833,7 +852,10 @@ export class InputController {
833
852
  /** Submit editor text to the focused subagent session (chat-only focus policy). */
834
853
  async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise<void> {
835
854
  const target = this.ctx.viewSession;
836
- if (!text) {
855
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
856
+ const imageLinks =
857
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
858
+ if (!text && !images) {
837
859
  if (target.isStreaming && target.queuedMessageCount > 0) {
838
860
  const aborting = target.abort({ reason: USER_INTERRUPT_LABEL });
839
861
  await aborting;
@@ -842,11 +864,10 @@ export class InputController {
842
864
  }
843
865
  return;
844
866
  }
845
- if (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text)) {
867
+ if (text && (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text))) {
846
868
  this.ctx.showStatus("Commands run in the main session — press ←← to return first");
847
869
  return; // editor text not cleared: Editor does not auto-clear on submit
848
870
  }
849
- const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
850
871
  this.ctx.editor.clearDraft(text);
851
872
  try {
852
873
  // prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
@@ -854,7 +875,14 @@ export class InputController {
854
875
  imageCount: images?.length ?? 0,
855
876
  });
856
877
  } catch (error) {
857
- this.ctx.editor.setText(text); // hand the message back, mirroring the main submit error path
878
+ // Hand the message back, mirroring the main submit error path: restore
879
+ // pasted images so the user can retry an image-only or text+image draft.
880
+ this.ctx.editor.setText(text);
881
+ if (images && images.length > 0) {
882
+ this.ctx.editor.pendingImages = [...images];
883
+ this.ctx.editor.pendingImageLinks = imageLinks ? [...imageLinks] : images.map(() => undefined);
884
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
885
+ }
858
886
  this.ctx.showError(error instanceof Error ? error.message : String(error));
859
887
  }
860
888
  this.ctx.updatePendingMessagesDisplay();
@@ -902,19 +930,19 @@ export class InputController {
902
930
  }
903
931
 
904
932
  handleCtrlZ(): void {
905
- // SIGTSTP is POSIX job-control: Windows has no equivalent and
906
- // `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
907
- // SIGTSTP` there, taking the whole agent down via an uncaught
908
- // exception (issue #2036). No-op on platforms that cannot suspend.
933
+ // Job-control suspend is POSIX-only: on Windows `process.kill(_, "SIGSTOP")`
934
+ // throws `TypeError: Unknown signal: SIGSTOP` and takes the whole agent down
935
+ // via an uncaught exception (issue #2036, originally for SIGTSTP — same
936
+ // shape for SIGSTOP). No-op on platforms that cannot suspend.
909
937
  if (process.platform === "win32") {
910
938
  this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
911
939
  return;
912
940
  }
913
941
 
914
- // Capture the listener so we can detach it if the signal never
915
- // fires; otherwise a failed suspend would leave a stale SIGCONT
916
- // handler that fires on the next unrelated continue and tries to
917
- // re-`start()` an already-running TUI.
942
+ // Capture the listener so we can detach it if the signal never fires;
943
+ // otherwise a failed suspend would leave a stale SIGCONT handler that
944
+ // fires on the next unrelated continue and tries to re-`start()` an
945
+ // already-running TUI.
918
946
  const onResume = (): void => {
919
947
  this.ctx.ui.start();
920
948
  this.ctx.ui.requestRender(true);
@@ -926,14 +954,41 @@ export class InputController {
926
954
  this.ctx.ui.stop();
927
955
 
928
956
  try {
929
- // pid=0 entire foreground process group; the shell receives
930
- // SIGTSTP and parks the job.
931
- process.kill(0, "SIGTSTP");
957
+ // SIGSTOP not SIGTSTP — to the foreground process group (pid=0).
958
+ //
959
+ // SIGTSTP: brush-core (the embedded shell behind every bash tool call)
960
+ // installs a tokio SIGTSTP listener on `Process::wait` to detect when
961
+ // its children have been stopped (`crates/brush-core-vendored/src/sys/
962
+ // unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::
963
+ // signal(SIGTSTP)`). Per tokio's documented contract, the first call
964
+ // for a given SignalKind permanently replaces the kernel-default
965
+ // handler for the lifetime of the process. So once the user has
966
+ // issued even one bash command — e.g. `/usr/bin/true` — SIGTSTP no
967
+ // longer stops omp: tokio swallows it and the TUI ends up torn down
968
+ // while the process keeps running with no live terminal (issue
969
+ // [#3461]). SIGSTOP cannot be caught, blocked, or ignored, so the
970
+ // kernel stops the process regardless of installed handlers.
971
+ //
972
+ // pid=0 (foreground process group, not just our PID): omp is not
973
+ // always the shell's direct child. Package-manager launchers (`npx`,
974
+ // `pnpm exec`, `bunx`, …) wait on the real CLI from a parent shim
975
+ // that shares omp's process group, and a `omp … | tee log` style
976
+ // pipeline puts a sibling foreground job member in the same group
977
+ // too. The shell sees the job as stopped only when its direct
978
+ // child / pipeline leader is stopped, so suspending only our PID
979
+ // leaves wrappers and pipeline peers running and the terminal
980
+ // hung — exactly the failure shape we're fixing. Stopping the whole
981
+ // group keeps the shell's job-control view consistent. Long-lived
982
+ // children that must survive the suspend (MCP stdio servers via
983
+ // the `detached: true` spawn in `mcp/transports/stdio.ts`, every
984
+ // brush external command via brush's per-child `setsid` in
985
+ // `crates/brush-core-vendored/src/commands.rs`) are already in
986
+ // their own sessions, so pgid=0 does not reach them.
987
+ process.kill(0, "SIGSTOP");
932
988
  } catch (err) {
933
- // Either the runtime refused the signal or the kernel rejected
934
- // it (some sandboxes block sending to pid=0). Tear the resume
935
- // hook down and bring the TUI back so the user is not stranded
936
- // on a frozen prompt.
989
+ // The runtime refused the signal (e.g. seccomp filter blocks SIGSTOP
990
+ // delivery to the process group). Tear the resume hook down and
991
+ // bring the TUI back so the user is not stranded on a frozen prompt.
937
992
  process.removeListener("SIGCONT", onResume);
938
993
  this.ctx.ui.start();
939
994
  this.ctx.ui.requestRender(true);
@@ -1024,7 +1079,10 @@ export class InputController {
1024
1079
  /** Send editor text as a follow-up message (queued behind current stream). */
1025
1080
  async handleFollowUp(): Promise<void> {
1026
1081
  let text = this.ctx.editor.getText().trim();
1027
- if (!text) return;
1082
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1083
+ const imageLinks =
1084
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
1085
+ if (!text && !images) return;
1028
1086
 
1029
1087
  // Focused subagent session: follow-ups go to it; non-chat input is gated.
1030
1088
  if (this.ctx.focusedAgentId) {
@@ -1044,38 +1102,53 @@ export class InputController {
1044
1102
  return;
1045
1103
  }
1046
1104
 
1047
- const slashResult = await executeBuiltinSlashCommand(text, {
1048
- ctx: this.ctx,
1049
- });
1050
- if (slashResult === true) {
1051
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1052
- return;
1053
- }
1054
- if (typeof slashResult === "string") {
1055
- // Command handled but returned remaining text to use as prompt.
1056
- // Record the original slash command text so Up Arrow recalls it.
1057
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1058
- text = slashResult;
1105
+ if (text) {
1106
+ const slashResult = await executeBuiltinSlashCommand(text, {
1107
+ ctx: this.ctx,
1108
+ });
1109
+ if (slashResult === true) {
1110
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1111
+ return;
1112
+ }
1113
+ if (typeof slashResult === "string") {
1114
+ // Command handled but returned remaining text to use as prompt.
1115
+ // Record the original slash command text so Up Arrow recalls it.
1116
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1117
+ text = slashResult;
1118
+ }
1059
1119
  }
1060
1120
 
1061
1121
  // Skill commands invoke through the custom-message path regardless of
1062
1122
  // which keybinding submitted them. Enter routes them as `steer`;
1063
1123
  // Ctrl+Enter (this handler) routes them as `followUp`.
1064
- if (await this.#invokeSkillCommand(text, "followUp")) {
1124
+ if (text && (await this.#invokeSkillCommand(text, "followUp"))) {
1065
1125
  return;
1066
1126
  }
1067
1127
 
1068
- // Forward any pending clipboard-pasted images alongside the queued text;
1069
- // otherwise the follow-up would drop the image (mirrors the Enter/steer path).
1070
- const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1128
+ // Hand the message back on dispatch failure (model/API-key validation,
1129
+ // queue rejection): restore both text AND pending images so an image-only
1130
+ // or text+image draft can be retried, mirroring the main submit error path.
1131
+ const restoreOnError = (error: unknown) => {
1132
+ this.ctx.editor.setText(text);
1133
+ if (images && images.length > 0) {
1134
+ this.ctx.editor.pendingImages = [...images];
1135
+ this.ctx.editor.pendingImageLinks = imageLinks ? [...imageLinks] : images.map(() => undefined);
1136
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
1137
+ }
1138
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1139
+ };
1071
1140
 
1072
1141
  if (this.ctx.session.isStreaming) {
1073
1142
  this.ctx.editor.clearDraft(text);
1074
- await this.ctx.withLocalSubmission(
1075
- text,
1076
- () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
1077
- { imageCount: images?.length ?? 0 },
1078
- );
1143
+ try {
1144
+ await this.ctx.withLocalSubmission(
1145
+ text,
1146
+ () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
1147
+ { imageCount: images?.length ?? 0 },
1148
+ );
1149
+ } catch (error) {
1150
+ restoreOnError(error);
1151
+ }
1079
1152
  this.ctx.updatePendingMessagesDisplay();
1080
1153
  this.ctx.ui.requestRender();
1081
1154
  return;
@@ -1083,9 +1156,13 @@ export class InputController {
1083
1156
 
1084
1157
  // Not streaming — just submit normally
1085
1158
  this.ctx.editor.clearDraft(text);
1086
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1087
- imageCount: images?.length ?? 0,
1088
- });
1159
+ try {
1160
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1161
+ imageCount: images?.length ?? 0,
1162
+ });
1163
+ } catch (error) {
1164
+ restoreOnError(error);
1165
+ }
1089
1166
  }
1090
1167
 
1091
1168
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
package/src/sdk.ts CHANGED
@@ -1520,6 +1520,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1520
1520
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1521
1521
  getActiveModelString,
1522
1522
  getActiveModel: () => agent?.state.model ?? model,
1523
+ getServiceTier: () => session?.serviceTier,
1523
1524
  getImageAttachments: () => session?.getImageAttachments() ?? [],
1524
1525
  getPlanModeState: () => session?.getPlanModeState(),
1525
1526
  getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
@@ -160,6 +160,7 @@ import {
160
160
  } from "../config/model-resolver";
161
161
  import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
162
162
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
163
+ import { resolveServiceTierSetting } from "../config/service-tier";
163
164
  import type { Settings, SkillsSettings } from "../config/settings";
164
165
  import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
165
166
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
@@ -1887,6 +1888,16 @@ export class AgentSession {
1887
1888
  }
1888
1889
  const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
1889
1890
 
1891
+ // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
1892
+ // advisor on standard processing; "inherit" tracks the session's live tier
1893
+ // per request (like the main agent, including /fast toggles) via a resolver;
1894
+ // a concrete value pins the advisor to that tier regardless of the session.
1895
+ const advisorTierSetting = this.settings.get("serviceTierAdvisor");
1896
+ const advisorServiceTier =
1897
+ advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
1898
+ const advisorServiceTierResolver =
1899
+ advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
1900
+
1890
1901
  // Thread the primary's telemetry into the advisor loop so the advisor
1891
1902
  // model's GenAI spans + usage/cost hooks fire like every other model call,
1892
1903
  // stamped with the advisor's own identity. `conversationId` is cleared so
@@ -1916,6 +1927,8 @@ export class AgentSession {
1916
1927
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
1917
1928
  intentTracing: false,
1918
1929
  telemetry: advisorTelemetry,
1930
+ serviceTier: advisorServiceTier,
1931
+ serviceTierResolver: advisorServiceTierResolver,
1919
1932
  });
1920
1933
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1921
1934
 
@@ -2797,6 +2810,9 @@ export class AgentSession {
2797
2810
  return;
2798
2811
  }
2799
2812
 
2813
+ const successfulYieldMessage = this.#findSuccessfulYieldAssistantMessage(settledMessages);
2814
+ const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2815
+
2800
2816
  const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
2801
2817
  logger.debug("agent_end maintenance routing", {
2802
2818
  route,
@@ -2808,7 +2824,7 @@ export class AgentSession {
2808
2824
  hasText: msg.content.some(content => content.type === "text"),
2809
2825
  goalModeEnabled: this.#goalModeState?.enabled === true,
2810
2826
  goalStatus: this.#goalModeState?.goal.status,
2811
- successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
2827
+ successfulYield: successfulYieldMessage !== undefined,
2812
2828
  ...extra,
2813
2829
  });
2814
2830
  };
@@ -2833,21 +2849,24 @@ export class AgentSession {
2833
2849
  }
2834
2850
 
2835
2851
  const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2836
- const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2837
2852
  // A successful `yield` in this run is terminal for execution purposes.
2838
2853
  // Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
2839
2854
  // and compaction-driven continuations for the rest of this prompt cycle:
2840
2855
  // the executor consumed the yield as the terminal result, so a trailing
2841
2856
  // empty/aborted assistant stop must NOT revive the agent loop. The
2842
2857
  // `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
2843
- if (yieldOnThisMessage || this.#yieldTerminationPending) {
2858
+ if (successfulYieldMessage || this.#yieldTerminationPending) {
2844
2859
  this.#lastSuccessfulYieldToolCallId = undefined;
2845
- if (yieldOnThisMessage && activeGoal) {
2846
- maintenanceRoute("successful-yield-active-goal-checkCompaction");
2847
- const compactionTask = this.#checkCompaction(msg);
2860
+ if (successfulYieldMessage && activeGoal) {
2861
+ maintenanceRoute(
2862
+ yieldOnThisMessage
2863
+ ? "successful-yield-active-goal-checkCompaction"
2864
+ : "post-yield-trailing-stop-active-goal-checkCompaction",
2865
+ );
2866
+ const compactionTask = this.#checkCompaction(successfulYieldMessage);
2848
2867
  this.#trackPostPromptTask(compactionTask);
2849
2868
  await compactionTask;
2850
- } else if (yieldOnThisMessage) {
2869
+ } else if (successfulYieldMessage) {
2851
2870
  maintenanceRoute("successful-yield-no-active-goal");
2852
2871
  } else {
2853
2872
  maintenanceRoute("post-yield-trailing-stop-suppressed");
@@ -6813,7 +6832,12 @@ export class AgentSession {
6813
6832
  * the transcript can distinguish a deliberate user interrupt from an opaque
6814
6833
  * abort. Omit it for internal/lifecycle aborts.
6815
6834
  */
6816
- async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
6835
+ async abort(options?: {
6836
+ goalReason?: "interrupted" | "internal";
6837
+ reason?: string;
6838
+ /** Internal `/compact` startup keeps the manual-compaction marker alive while aborting the active turn. */
6839
+ preserveCompaction?: boolean;
6840
+ }): Promise<void> {
6817
6841
  const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
6818
6842
  if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
6819
6843
  // Pull advisor concerns out of the steer/follow-up queues before any await so
@@ -6828,7 +6852,17 @@ export class AgentSession {
6828
6852
  this.abortRetry();
6829
6853
  this.#promptGeneration++;
6830
6854
  this.#scheduledHiddenNextTurnGeneration = undefined;
6831
- this.abortCompaction();
6855
+ if (options?.preserveCompaction) {
6856
+ // Manual `/compact` installed its own #compactionAbortController before
6857
+ // this internal abort and must keep it alive (that marker is what makes
6858
+ // isCompacting report true during startup). Any in-flight
6859
+ // auto-compaction MUST still be cancelled, though: otherwise a
6860
+ // background maintenance pass races the manual run and both
6861
+ // appendCompaction/replaceMessages, double-rewriting session history.
6862
+ this.#autoCompactionAbortController?.abort();
6863
+ } else {
6864
+ this.abortCompaction();
6865
+ }
6832
6866
  this.abortHandoff();
6833
6867
  this.abortBash();
6834
6868
  this.abortEval();
@@ -7801,12 +7835,12 @@ export class AgentSession {
7801
7835
  if (compactMode?.rejectsFocus && customInstructions) {
7802
7836
  throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
7803
7837
  }
7804
- this.#disconnectFromAgent();
7805
- await this.abort({ goalReason: "internal" });
7806
7838
  const compactionAbortController = new AbortController();
7807
7839
  this.#compactionAbortController = compactionAbortController;
7808
7840
 
7809
7841
  try {
7842
+ this.#disconnectFromAgent();
7843
+ await this.abort({ goalReason: "internal", preserveCompaction: true });
7810
7844
  if (!this.model) {
7811
7845
  throw new Error("No model selected");
7812
7846
  }
@@ -8591,9 +8625,7 @@ export class AgentSession {
8591
8625
  }
8592
8626
  return COMPACTION_CHECK_NONE;
8593
8627
  }
8594
- #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
8595
- const toolCallId = this.#lastSuccessfulYieldToolCallId;
8596
- if (!toolCallId) return false;
8628
+ #assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean {
8597
8629
  const lastToolCall = assistantMessage.content
8598
8630
  .slice()
8599
8631
  .reverse()
@@ -8601,6 +8633,22 @@ export class AgentSession {
8601
8633
  return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
8602
8634
  }
8603
8635
 
8636
+ #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
8637
+ const toolCallId = this.#lastSuccessfulYieldToolCallId;
8638
+ return toolCallId ? this.#assistantMessageHasSuccessfulYieldToolCall(assistantMessage, toolCallId) : false;
8639
+ }
8640
+
8641
+ #findSuccessfulYieldAssistantMessage(messages: readonly AgentMessage[]): AssistantMessage | undefined {
8642
+ const toolCallId = this.#lastSuccessfulYieldToolCallId;
8643
+ if (!toolCallId) return undefined;
8644
+ for (let i = messages.length - 1; i >= 0; i--) {
8645
+ const message = messages[i];
8646
+ if (message.role !== "assistant") continue;
8647
+ if (this.#assistantMessageHasSuccessfulYieldToolCall(message, toolCallId)) return message;
8648
+ }
8649
+ return undefined;
8650
+ }
8651
+
8604
8652
  async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
8605
8653
  if (!this.#isEmptyAssistantStop(assistantMessage)) {
8606
8654
  this.#emptyStopRetryCount = 0;
@@ -10619,7 +10667,16 @@ export class AgentSession {
10619
10667
  #getRetryFallbackChains(): RetryFallbackChains {
10620
10668
  const configuredChains = this.settings.get("retry.fallbackChains");
10621
10669
  if (!configuredChains || typeof configuredChains !== "object") return {};
10622
- return configuredChains as RetryFallbackChains;
10670
+ const chains: RetryFallbackChains = { ...(configuredChains as RetryFallbackChains) };
10671
+ const defaultChain = chains.default;
10672
+ if (Array.isArray(defaultChain)) {
10673
+ for (const role of Object.keys(this.settings.getModelRoles())) {
10674
+ if (role !== "default" && chains[role] === undefined) {
10675
+ chains[role] = defaultChain;
10676
+ }
10677
+ }
10678
+ }
10679
+ return chains;
10623
10680
  }
10624
10681
 
10625
10682
  #validateRetryFallbackChains(): void {
@@ -86,6 +86,14 @@ function lineCount(text: string): number {
86
86
  return text.split("\n").length;
87
87
  }
88
88
 
89
+ function primaryArgValue(value: unknown): string {
90
+ if (typeof value === "string" && value.length > 0) return value;
91
+ if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
92
+ return value.join(", ");
93
+ }
94
+ return "";
95
+ }
96
+
89
97
  /** Pick the most informative scalar argument of a tool call. */
90
98
  function primaryArg(name: string, args: Record<string, unknown> | undefined): string {
91
99
  if (!args || typeof args !== "object") return "";
@@ -97,12 +105,21 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
97
105
  if (note) return oneLine(note);
98
106
  if (severity) return oneLine(severity);
99
107
  }
108
+ if (name === "search") {
109
+ const pattern = primaryArgValue(args.pattern);
110
+ const paths = primaryArgValue(args.paths);
111
+ if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
112
+ if (pattern) return oneLine(pattern);
113
+ if (paths) return oneLine(paths);
114
+ }
115
+ if (name === "find") {
116
+ const paths = primaryArgValue(args.paths);
117
+ if (paths) return oneLine(paths);
118
+ }
100
119
  for (const key of PRIMARY_ARG_KEYS) {
101
120
  const value = args[key];
102
- if (typeof value === "string" && value.length > 0) return oneLine(value);
103
- if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
104
- return oneLine(value.join(", "));
105
- }
121
+ const summary = primaryArgValue(value);
122
+ if (summary) return oneLine(summary);
106
123
  }
107
124
  // Fallback: first non-intent string arg, then a compact JSON of the args.
108
125
  const rest: Record<string, unknown> = {};