@oh-my-pi/pi-coding-agent 16.1.18 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +15452 -15142
  3. package/dist/types/cli/gallery-cli.d.ts +6 -0
  4. package/dist/types/commands/gallery.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +34 -0
  6. package/dist/types/config/settings-schema.d.ts +36 -33
  7. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  8. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  9. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +4 -1
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +12 -0
  11. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  12. package/dist/types/internal-urls/types.d.ts +3 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  14. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  15. package/dist/types/session/agent-session.d.ts +2 -0
  16. package/dist/types/task/executor.d.ts +9 -1
  17. package/dist/types/task/parallel.d.ts +17 -1
  18. package/dist/types/tools/index.d.ts +3 -1
  19. package/dist/types/tools/path-utils.d.ts +3 -0
  20. package/package.json +13 -13
  21. package/scripts/build-binary.ts +20 -0
  22. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  23. package/src/cli/gallery-cli.ts +31 -2
  24. package/src/cli/gallery-fixtures/agentic.ts +13 -4
  25. package/src/commands/gallery.ts +11 -3
  26. package/src/config/service-tier.ts +87 -0
  27. package/src/config/settings-schema.ts +48 -23
  28. package/src/edit/hashline/filesystem.ts +14 -0
  29. package/src/eval/agent-bridge.ts +2 -0
  30. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  31. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3330 -18
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +29 -14
  33. package/src/internal-urls/local-protocol.ts +116 -9
  34. package/src/internal-urls/skill-protocol.ts +3 -3
  35. package/src/internal-urls/types.ts +3 -0
  36. package/src/main.ts +1 -0
  37. package/src/mcp/transports/stdio.ts +5 -0
  38. package/src/modes/components/custom-editor.test.ts +7 -5
  39. package/src/modes/components/custom-editor.ts +6 -16
  40. package/src/modes/controllers/input-controller.ts +143 -137
  41. package/src/sdk.ts +1 -0
  42. package/src/session/agent-session.ts +72 -15
  43. package/src/session/messages.ts +70 -47
  44. package/src/session/session-history-format.ts +21 -4
  45. package/src/task/executor.ts +79 -2
  46. package/src/task/index.ts +11 -6
  47. package/src/task/parallel.ts +59 -7
  48. package/src/tools/ast-edit.ts +1 -0
  49. package/src/tools/ast-grep.ts +1 -0
  50. package/src/tools/find.ts +1 -0
  51. package/src/tools/index.ts +3 -1
  52. package/src/tools/irc.ts +16 -2
  53. package/src/tools/path-utils.ts +4 -0
  54. package/src/tools/read.ts +43 -17
  55. package/src/tools/search.ts +4 -0
  56. package/src/utils/shell-snapshot-fn-env.sh +60 -0
  57. package/src/utils/shell-snapshot.ts +77 -20
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import type { ImageContent } from "@oh-my-pi/pi-ai";
5
4
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
6
5
  import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
@@ -20,7 +19,6 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
20
19
  import { isLowSignalTitleInput } from "../../tiny/text";
21
20
  import { tinyTitleClient } from "../../tiny/title-client";
22
21
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
23
- import { resolveReadPath } from "../../tools/path-utils";
24
22
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
25
23
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
26
24
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
@@ -104,20 +102,6 @@ function wrapPasteInAttachmentBlock(content: string): string {
104
102
  return `<attachment>\n${content}\n</attachment>`;
105
103
  }
106
104
 
107
- const FILE_URI_REGEX = /^file:\/\//i;
108
-
109
- function pastedFileAttachmentExtension(sourcePath: string): string {
110
- const ext = path.extname(sourcePath);
111
- const bareExt = ext.slice(1);
112
- if (!bareExt || bareExt.length > 32 || !/^[a-z0-9][a-z0-9._-]*$/i.test(bareExt)) return "";
113
- return ext;
114
- }
115
-
116
- function resolvePastedFilePath(filePath: string, cwd: string): string {
117
- if (FILE_URI_REGEX.test(filePath)) return fileURLToPath(filePath);
118
- return resolveReadPath(filePath, cwd);
119
- }
120
-
121
105
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
122
106
  // A cached model fires its file-load events in a short burst and then goes silent
123
107
  // while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -392,7 +376,6 @@ export class InputController {
392
376
  );
393
377
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
394
378
  this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
395
- this.ctx.editor.onPasteFilePath = path => this.handleFilePathPaste(path);
396
379
  this.ctx.editor.setActionKeys(
397
380
  "app.clipboard.pasteTextRaw",
398
381
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -551,6 +534,7 @@ export class InputController {
551
534
  setupEditorSubmitHandler(): void {
552
535
  this.ctx.editor.onSubmit = async (text: string) => {
553
536
  text = text.trim();
537
+ const hasPendingImages = this.ctx.editor.pendingImages.length > 0;
554
538
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
555
539
 
556
540
  // Focused subagent session: the editor is a plain chat box for it.
@@ -563,7 +547,7 @@ export class InputController {
563
547
 
564
548
  // Empty submit while streaming with queued messages: abort the active
565
549
  // turn and let the post-unwind drain deliver the agent-core queue.
566
- if (!text && this.ctx.session.isStreaming) {
550
+ if (!text && !hasPendingImages && this.ctx.session.isStreaming) {
567
551
  if (this.ctx.session.queuedMessageCount > 0) {
568
552
  const aborting = this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
569
553
  await aborting;
@@ -573,7 +557,7 @@ export class InputController {
573
557
  return;
574
558
  }
575
559
 
576
- if (!text) return;
560
+ if (!text && !hasPendingImages) return;
577
561
 
578
562
  // Continue shortcuts: "." or "c" resume the agent with a hidden agent-authored
579
563
  // developer directive (no visible user message) instead of an empty turn, so the
@@ -596,6 +580,7 @@ export class InputController {
596
580
  let inputImages = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
597
581
  let inputImageLinks =
598
582
  this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
583
+ let hasInputImages = (inputImages?.length ?? 0) > 0;
599
584
 
600
585
  if (runner?.hasHandlers("input")) {
601
586
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -613,24 +598,27 @@ export class InputController {
613
598
  this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
614
599
  );
615
600
  }
601
+ hasInputImages = (inputImages?.length ?? 0) > 0;
616
602
  }
617
603
 
618
- if (!text) return;
604
+ if (!text && !hasInputImages) return;
619
605
 
620
606
  // Handle built-in slash commands
621
- const slashResult = await executeBuiltinSlashCommand(text, {
622
- ctx: this.ctx,
623
- });
624
- if (slashResult === true) {
625
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
626
- return;
627
- }
628
- if (typeof slashResult === "string") {
629
- // Command handled but returned remaining text to use as prompt.
630
- // Record the original slash command text so Up Arrow recalls
631
- // "/loop 10 fix bug" rather than just "fix bug".
632
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
633
- 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
+ }
634
622
  }
635
623
 
636
624
  // Collab guest: prompts execute on the host; local slash/skill/bash/
@@ -664,7 +652,7 @@ export class InputController {
664
652
  // free-text Enter semantics applied a few lines below at the streaming
665
653
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
666
654
  // same helper with `"followUp"`.
667
- if (await this.#invokeSkillCommand(text, "steer")) {
655
+ if (text && (await this.#invokeSkillCommand(text, "steer"))) {
668
656
  return;
669
657
  }
670
658
 
@@ -731,11 +719,25 @@ export class InputController {
731
719
  // (a user-role `message_start` event) leaves any draft the user has
732
720
  // typed since queuing intact. Same protection as #783, applied to
733
721
  // the streaming/queue path.
734
- await this.ctx.withLocalSubmission(
735
- text,
736
- () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
737
- { imageCount: images?.length ?? 0 },
738
- );
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
+ }
739
741
  this.ctx.updatePendingMessagesDisplay();
740
742
  this.ctx.ui.requestRender();
741
743
  return;
@@ -850,7 +852,10 @@ export class InputController {
850
852
  /** Submit editor text to the focused subagent session (chat-only focus policy). */
851
853
  async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise<void> {
852
854
  const target = this.ctx.viewSession;
853
- 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) {
854
859
  if (target.isStreaming && target.queuedMessageCount > 0) {
855
860
  const aborting = target.abort({ reason: USER_INTERRUPT_LABEL });
856
861
  await aborting;
@@ -859,11 +864,10 @@ export class InputController {
859
864
  }
860
865
  return;
861
866
  }
862
- if (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text)) {
867
+ if (text && (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text))) {
863
868
  this.ctx.showStatus("Commands run in the main session — press ←← to return first");
864
869
  return; // editor text not cleared: Editor does not auto-clear on submit
865
870
  }
866
- const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
867
871
  this.ctx.editor.clearDraft(text);
868
872
  try {
869
873
  // prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
@@ -871,7 +875,14 @@ export class InputController {
871
875
  imageCount: images?.length ?? 0,
872
876
  });
873
877
  } catch (error) {
874
- 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
+ }
875
886
  this.ctx.showError(error instanceof Error ? error.message : String(error));
876
887
  }
877
888
  this.ctx.updatePendingMessagesDisplay();
@@ -919,19 +930,19 @@ export class InputController {
919
930
  }
920
931
 
921
932
  handleCtrlZ(): void {
922
- // SIGTSTP is POSIX job-control: Windows has no equivalent and
923
- // `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
924
- // SIGTSTP` there, taking the whole agent down via an uncaught
925
- // 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.
926
937
  if (process.platform === "win32") {
927
938
  this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
928
939
  return;
929
940
  }
930
941
 
931
- // Capture the listener so we can detach it if the signal never
932
- // fires; otherwise a failed suspend would leave a stale SIGCONT
933
- // handler that fires on the next unrelated continue and tries to
934
- // 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.
935
946
  const onResume = (): void => {
936
947
  this.ctx.ui.start();
937
948
  this.ctx.ui.requestRender(true);
@@ -943,14 +954,41 @@ export class InputController {
943
954
  this.ctx.ui.stop();
944
955
 
945
956
  try {
946
- // pid=0 entire foreground process group; the shell receives
947
- // SIGTSTP and parks the job.
948
- 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");
949
988
  } catch (err) {
950
- // Either the runtime refused the signal or the kernel rejected
951
- // it (some sandboxes block sending to pid=0). Tear the resume
952
- // hook down and bring the TUI back so the user is not stranded
953
- // 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.
954
992
  process.removeListener("SIGCONT", onResume);
955
993
  this.ctx.ui.start();
956
994
  this.ctx.ui.requestRender(true);
@@ -1041,7 +1079,10 @@ export class InputController {
1041
1079
  /** Send editor text as a follow-up message (queued behind current stream). */
1042
1080
  async handleFollowUp(): Promise<void> {
1043
1081
  let text = this.ctx.editor.getText().trim();
1044
- 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;
1045
1086
 
1046
1087
  // Focused subagent session: follow-ups go to it; non-chat input is gated.
1047
1088
  if (this.ctx.focusedAgentId) {
@@ -1061,38 +1102,53 @@ export class InputController {
1061
1102
  return;
1062
1103
  }
1063
1104
 
1064
- const slashResult = await executeBuiltinSlashCommand(text, {
1065
- ctx: this.ctx,
1066
- });
1067
- if (slashResult === true) {
1068
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1069
- return;
1070
- }
1071
- if (typeof slashResult === "string") {
1072
- // Command handled but returned remaining text to use as prompt.
1073
- // Record the original slash command text so Up Arrow recalls it.
1074
- if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1075
- 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
+ }
1076
1119
  }
1077
1120
 
1078
1121
  // Skill commands invoke through the custom-message path regardless of
1079
1122
  // which keybinding submitted them. Enter routes them as `steer`;
1080
1123
  // Ctrl+Enter (this handler) routes them as `followUp`.
1081
- if (await this.#invokeSkillCommand(text, "followUp")) {
1124
+ if (text && (await this.#invokeSkillCommand(text, "followUp"))) {
1082
1125
  return;
1083
1126
  }
1084
1127
 
1085
- // Forward any pending clipboard-pasted images alongside the queued text;
1086
- // otherwise the follow-up would drop the image (mirrors the Enter/steer path).
1087
- 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
+ };
1088
1140
 
1089
1141
  if (this.ctx.session.isStreaming) {
1090
1142
  this.ctx.editor.clearDraft(text);
1091
- await this.ctx.withLocalSubmission(
1092
- text,
1093
- () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
1094
- { imageCount: images?.length ?? 0 },
1095
- );
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
+ }
1096
1152
  this.ctx.updatePendingMessagesDisplay();
1097
1153
  this.ctx.ui.requestRender();
1098
1154
  return;
@@ -1100,9 +1156,13 @@ export class InputController {
1100
1156
 
1101
1157
  // Not streaming — just submit normally
1102
1158
  this.ctx.editor.clearDraft(text);
1103
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1104
- imageCount: images?.length ?? 0,
1105
- });
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
+ }
1106
1166
  }
1107
1167
 
1108
1168
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
@@ -1261,37 +1321,6 @@ export class InputController {
1261
1321
  }
1262
1322
  }
1263
1323
 
1264
- async handleFilePathPaste(filePath: string): Promise<void> {
1265
- try {
1266
- const resolvedPath = resolvePastedFilePath(filePath, this.ctx.sessionManager.getCwd());
1267
- const stat = await Bun.file(resolvedPath).stat();
1268
- if (!stat.isFile()) {
1269
- this.ctx.editor.pasteText(filePath);
1270
- this.ctx.ui.requestRender();
1271
- this.ctx.showStatus("Pasted path is not a file");
1272
- return;
1273
- }
1274
-
1275
- const reference = await this.#attachExistingFileAsLocal(resolvedPath);
1276
- this.ctx.editor.insertText(`${reference} `);
1277
- this.ctx.ui.requestRender();
1278
- this.ctx.showStatus(`Attached file as ${reference}`);
1279
- } catch (error) {
1280
- if (isEnoent(error)) {
1281
- this.ctx.editor.pasteText(filePath);
1282
- this.ctx.ui.requestRender();
1283
- this.ctx.showStatus("Pasted file path was not found");
1284
- return;
1285
- }
1286
- logger.warn("failed to attach pasted file path", {
1287
- error: error instanceof Error ? error.message : String(error),
1288
- });
1289
- this.ctx.editor.pasteText(filePath);
1290
- this.ctx.ui.requestRender();
1291
- this.ctx.showError("Failed to attach pasted file path — pasted path inline instead");
1292
- }
1293
- }
1294
-
1295
1324
  async handleImagePathPaste(path: string): Promise<void> {
1296
1325
  try {
1297
1326
  const image = await loadImageInput({
@@ -1462,29 +1491,6 @@ export class InputController {
1462
1491
  this.ctx.ui.requestRender();
1463
1492
  }
1464
1493
 
1465
- async #attachExistingFileAsLocal(sourcePath: string): Promise<string> {
1466
- const localRoot = resolveLocalRoot({
1467
- getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
1468
- getSessionId: () => this.ctx.sessionManager.getSessionId(),
1469
- });
1470
- await fs.mkdir(localRoot, { recursive: true });
1471
- const ext = pastedFileAttachmentExtension(sourcePath);
1472
- let name: string;
1473
- let filePath: string;
1474
- do {
1475
- this.#attachmentCounter++;
1476
- name = `attachment-${this.#attachmentCounter}${ext}`;
1477
- filePath = path.join(localRoot, name);
1478
- } while (await Bun.file(filePath).exists());
1479
-
1480
- try {
1481
- await fs.link(sourcePath, filePath);
1482
- } catch {
1483
- await fs.copyFile(sourcePath, filePath);
1484
- }
1485
- return `local://${name}`;
1486
- }
1487
-
1488
1494
  /**
1489
1495
  * Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
1490
1496
  * reference into the editor so the agent can `read` it on demand — instead of inlining the text or
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 {