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

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 (59) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +3795 -3760
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/cli/gallery-cli.d.ts +6 -0
  5. package/dist/types/commands/gallery.d.ts +1 -1
  6. package/dist/types/config/service-tier.d.ts +34 -0
  7. package/dist/types/config/settings-schema.d.ts +36 -33
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  9. package/dist/types/mcp/oauth-flow.d.ts +42 -5
  10. package/dist/types/modes/components/custom-editor.d.ts +32 -0
  11. package/dist/types/modes/components/tool-execution.d.ts +5 -5
  12. package/dist/types/modes/controllers/event-controller.d.ts +10 -0
  13. package/dist/types/modes/controllers/input-controller.d.ts +2 -1
  14. package/dist/types/session/agent-session.d.ts +2 -0
  15. package/dist/types/task/executor.d.ts +9 -1
  16. package/dist/types/task/parallel.d.ts +17 -1
  17. package/dist/types/tools/index.d.ts +3 -1
  18. package/dist/types/utils/clipboard.d.ts +10 -0
  19. package/package.json +13 -13
  20. package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
  21. package/src/advisor/__tests__/advisor.test.ts +44 -0
  22. package/src/advisor/advise-tool.ts +33 -0
  23. package/src/autolearn/controller.ts +17 -2
  24. package/src/cli/gallery-cli.ts +31 -2
  25. package/src/cli/gallery-fixtures/agentic.ts +13 -4
  26. package/src/commands/gallery.ts +11 -3
  27. package/src/config/service-tier.ts +87 -0
  28. package/src/config/settings-schema.ts +48 -23
  29. package/src/eval/agent-bridge.ts +2 -0
  30. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
  31. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
  33. package/src/main.ts +1 -0
  34. package/src/mcp/manager.ts +12 -3
  35. package/src/mcp/oauth-flow.ts +121 -7
  36. package/src/mcp/transports/stdio.ts +5 -0
  37. package/src/modes/components/chat-transcript-builder.ts +31 -0
  38. package/src/modes/components/custom-editor.test.ts +80 -0
  39. package/src/modes/components/custom-editor.ts +86 -6
  40. package/src/modes/components/tool-execution.ts +50 -25
  41. package/src/modes/controllers/event-controller.ts +57 -8
  42. package/src/modes/controllers/input-controller.ts +213 -93
  43. package/src/modes/controllers/mcp-command-controller.ts +18 -2
  44. package/src/modes/utils/ui-helpers.ts +40 -0
  45. package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
  46. package/src/prompts/system/autolearn-nudge.md +4 -2
  47. package/src/prompts/tools/todo.md +1 -1
  48. package/src/sdk.ts +1 -0
  49. package/src/session/agent-session.ts +76 -15
  50. package/src/session/session-history-format.ts +21 -4
  51. package/src/task/executor.ts +79 -2
  52. package/src/task/index.ts +11 -6
  53. package/src/task/parallel.ts +59 -7
  54. package/src/tools/index.ts +3 -1
  55. package/src/tools/irc.ts +16 -2
  56. package/src/tools/todo.ts +20 -10
  57. package/src/utils/clipboard.ts +57 -0
  58. package/src/utils/shell-snapshot-fn-env.sh +60 -0
  59. package/src/utils/shell-snapshot.ts +77 -20
@@ -6,6 +6,7 @@ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
7
  import { resolveLocalRoot } from "../../internal-urls";
8
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
9
+ import { extractImagePathFromText } from "../../modes/components/custom-editor";
9
10
  import { renderSegmentTrack } from "../../modes/components/segment-track";
10
11
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
11
12
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
@@ -20,7 +21,12 @@ import { isLowSignalTitleInput } from "../../tiny/text";
20
21
  import { tinyTitleClient } from "../../tiny/title-client";
21
22
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
22
23
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
23
- import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
24
+ import {
25
+ copyToClipboard,
26
+ readImageFromClipboard,
27
+ readMacFileUrlsFromClipboard,
28
+ readTextFromClipboard,
29
+ } from "../../utils/clipboard";
24
30
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
25
31
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
26
32
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
@@ -126,7 +132,12 @@ export class InputController {
126
132
  private clipboard: {
127
133
  readImage: typeof readImageFromClipboard;
128
134
  readText: typeof readTextFromClipboard;
129
- } = { readImage: readImageFromClipboard, readText: readTextFromClipboard },
135
+ readMacFileUrls?: typeof readMacFileUrlsFromClipboard;
136
+ } = {
137
+ readImage: readImageFromClipboard,
138
+ readText: readTextFromClipboard,
139
+ readMacFileUrls: readMacFileUrlsFromClipboard,
140
+ },
130
141
  ) {}
131
142
 
132
143
  #enhancedPaste?: EnhancedPasteController;
@@ -534,6 +545,7 @@ export class InputController {
534
545
  setupEditorSubmitHandler(): void {
535
546
  this.ctx.editor.onSubmit = async (text: string) => {
536
547
  text = text.trim();
548
+ const hasPendingImages = this.ctx.editor.pendingImages.length > 0;
537
549
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
538
550
 
539
551
  // Focused subagent session: the editor is a plain chat box for it.
@@ -546,7 +558,7 @@ export class InputController {
546
558
 
547
559
  // Empty submit while streaming with queued messages: abort the active
548
560
  // turn and let the post-unwind drain deliver the agent-core queue.
549
- if (!text && this.ctx.session.isStreaming) {
561
+ if (!text && !hasPendingImages && this.ctx.session.isStreaming) {
550
562
  if (this.ctx.session.queuedMessageCount > 0) {
551
563
  const aborting = this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
552
564
  await aborting;
@@ -556,7 +568,7 @@ export class InputController {
556
568
  return;
557
569
  }
558
570
 
559
- if (!text) return;
571
+ if (!text && !hasPendingImages) return;
560
572
 
561
573
  // Continue shortcuts: "." or "c" resume the agent with a hidden agent-authored
562
574
  // developer directive (no visible user message) instead of an empty turn, so the
@@ -579,6 +591,7 @@ export class InputController {
579
591
  let inputImages = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
580
592
  let inputImageLinks =
581
593
  this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
594
+ let hasInputImages = (inputImages?.length ?? 0) > 0;
582
595
 
583
596
  if (runner?.hasHandlers("input")) {
584
597
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -596,24 +609,27 @@ export class InputController {
596
609
  this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
597
610
  );
598
611
  }
612
+ hasInputImages = (inputImages?.length ?? 0) > 0;
599
613
  }
600
614
 
601
- if (!text) return;
615
+ if (!text && !hasInputImages) return;
602
616
 
603
617
  // 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;
618
+ if (text) {
619
+ const slashResult = await executeBuiltinSlashCommand(text, {
620
+ ctx: this.ctx,
621
+ });
622
+ if (slashResult === true) {
623
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
624
+ return;
625
+ }
626
+ if (typeof slashResult === "string") {
627
+ // Command handled but returned remaining text to use as prompt.
628
+ // Record the original slash command text so Up Arrow recalls
629
+ // "/loop 10 fix bug" rather than just "fix bug".
630
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
631
+ text = slashResult;
632
+ }
617
633
  }
618
634
 
619
635
  // Collab guest: prompts execute on the host; local slash/skill/bash/
@@ -647,7 +663,7 @@ export class InputController {
647
663
  // free-text Enter semantics applied a few lines below at the streaming
648
664
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
649
665
  // same helper with `"followUp"`.
650
- if (await this.#invokeSkillCommand(text, "steer")) {
666
+ if (text && (await this.#invokeSkillCommand(text, "steer"))) {
651
667
  return;
652
668
  }
653
669
 
@@ -714,11 +730,25 @@ export class InputController {
714
730
  // (a user-role `message_start` event) leaves any draft the user has
715
731
  // typed since queuing intact. Same protection as #783, applied to
716
732
  // 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
- );
733
+ try {
734
+ await this.ctx.withLocalSubmission(
735
+ text,
736
+ () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
737
+ { imageCount: images?.length ?? 0 },
738
+ );
739
+ } catch (error) {
740
+ // Don't lose the queued steer draft: restore text and images so
741
+ // the user can retry after dispatch validation/queue failures.
742
+ this.ctx.editor.setText(text);
743
+ if (images && images.length > 0) {
744
+ this.ctx.editor.pendingImages = [...images];
745
+ this.ctx.editor.pendingImageLinks = inputImageLinks
746
+ ? [...inputImageLinks]
747
+ : images.map(() => undefined);
748
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
749
+ }
750
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
751
+ }
722
752
  this.ctx.updatePendingMessagesDisplay();
723
753
  this.ctx.ui.requestRender();
724
754
  return;
@@ -833,7 +863,10 @@ export class InputController {
833
863
  /** Submit editor text to the focused subagent session (chat-only focus policy). */
834
864
  async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise<void> {
835
865
  const target = this.ctx.viewSession;
836
- if (!text) {
866
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
867
+ const imageLinks =
868
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
869
+ if (!text && !images) {
837
870
  if (target.isStreaming && target.queuedMessageCount > 0) {
838
871
  const aborting = target.abort({ reason: USER_INTERRUPT_LABEL });
839
872
  await aborting;
@@ -842,11 +875,10 @@ export class InputController {
842
875
  }
843
876
  return;
844
877
  }
845
- if (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text)) {
878
+ if (text && (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text))) {
846
879
  this.ctx.showStatus("Commands run in the main session — press ←← to return first");
847
880
  return; // editor text not cleared: Editor does not auto-clear on submit
848
881
  }
849
- const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
850
882
  this.ctx.editor.clearDraft(text);
851
883
  try {
852
884
  // prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
@@ -854,7 +886,14 @@ export class InputController {
854
886
  imageCount: images?.length ?? 0,
855
887
  });
856
888
  } catch (error) {
857
- this.ctx.editor.setText(text); // hand the message back, mirroring the main submit error path
889
+ // Hand the message back, mirroring the main submit error path: restore
890
+ // pasted images so the user can retry an image-only or text+image draft.
891
+ this.ctx.editor.setText(text);
892
+ if (images && images.length > 0) {
893
+ this.ctx.editor.pendingImages = [...images];
894
+ this.ctx.editor.pendingImageLinks = imageLinks ? [...imageLinks] : images.map(() => undefined);
895
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
896
+ }
858
897
  this.ctx.showError(error instanceof Error ? error.message : String(error));
859
898
  }
860
899
  this.ctx.updatePendingMessagesDisplay();
@@ -902,19 +941,19 @@ export class InputController {
902
941
  }
903
942
 
904
943
  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.
944
+ // Job-control suspend is POSIX-only: on Windows `process.kill(_, "SIGSTOP")`
945
+ // throws `TypeError: Unknown signal: SIGSTOP` and takes the whole agent down
946
+ // via an uncaught exception (issue #2036, originally for SIGTSTP — same
947
+ // shape for SIGSTOP). No-op on platforms that cannot suspend.
909
948
  if (process.platform === "win32") {
910
949
  this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
911
950
  return;
912
951
  }
913
952
 
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.
953
+ // Capture the listener so we can detach it if the signal never fires;
954
+ // otherwise a failed suspend would leave a stale SIGCONT handler that
955
+ // fires on the next unrelated continue and tries to re-`start()` an
956
+ // already-running TUI.
918
957
  const onResume = (): void => {
919
958
  this.ctx.ui.start();
920
959
  this.ctx.ui.requestRender(true);
@@ -926,14 +965,41 @@ export class InputController {
926
965
  this.ctx.ui.stop();
927
966
 
928
967
  try {
929
- // pid=0 entire foreground process group; the shell receives
930
- // SIGTSTP and parks the job.
931
- process.kill(0, "SIGTSTP");
968
+ // SIGSTOP not SIGTSTP — to the foreground process group (pid=0).
969
+ //
970
+ // SIGTSTP: brush-core (the embedded shell behind every bash tool call)
971
+ // installs a tokio SIGTSTP listener on `Process::wait` to detect when
972
+ // its children have been stopped (`crates/brush-core-vendored/src/sys/
973
+ // unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::
974
+ // signal(SIGTSTP)`). Per tokio's documented contract, the first call
975
+ // for a given SignalKind permanently replaces the kernel-default
976
+ // handler for the lifetime of the process. So once the user has
977
+ // issued even one bash command — e.g. `/usr/bin/true` — SIGTSTP no
978
+ // longer stops omp: tokio swallows it and the TUI ends up torn down
979
+ // while the process keeps running with no live terminal (issue
980
+ // [#3461]). SIGSTOP cannot be caught, blocked, or ignored, so the
981
+ // kernel stops the process regardless of installed handlers.
982
+ //
983
+ // pid=0 (foreground process group, not just our PID): omp is not
984
+ // always the shell's direct child. Package-manager launchers (`npx`,
985
+ // `pnpm exec`, `bunx`, …) wait on the real CLI from a parent shim
986
+ // that shares omp's process group, and a `omp … | tee log` style
987
+ // pipeline puts a sibling foreground job member in the same group
988
+ // too. The shell sees the job as stopped only when its direct
989
+ // child / pipeline leader is stopped, so suspending only our PID
990
+ // leaves wrappers and pipeline peers running and the terminal
991
+ // hung — exactly the failure shape we're fixing. Stopping the whole
992
+ // group keeps the shell's job-control view consistent. Long-lived
993
+ // children that must survive the suspend (MCP stdio servers via
994
+ // the `detached: true` spawn in `mcp/transports/stdio.ts`, every
995
+ // brush external command via brush's per-child `setsid` in
996
+ // `crates/brush-core-vendored/src/commands.rs`) are already in
997
+ // their own sessions, so pgid=0 does not reach them.
998
+ process.kill(0, "SIGSTOP");
932
999
  } 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.
1000
+ // The runtime refused the signal (e.g. seccomp filter blocks SIGSTOP
1001
+ // delivery to the process group). Tear the resume hook down and
1002
+ // bring the TUI back so the user is not stranded on a frozen prompt.
937
1003
  process.removeListener("SIGCONT", onResume);
938
1004
  this.ctx.ui.start();
939
1005
  this.ctx.ui.requestRender(true);
@@ -1024,7 +1090,10 @@ export class InputController {
1024
1090
  /** Send editor text as a follow-up message (queued behind current stream). */
1025
1091
  async handleFollowUp(): Promise<void> {
1026
1092
  let text = this.ctx.editor.getText().trim();
1027
- if (!text) return;
1093
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1094
+ const imageLinks =
1095
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
1096
+ if (!text && !images) return;
1028
1097
 
1029
1098
  // Focused subagent session: follow-ups go to it; non-chat input is gated.
1030
1099
  if (this.ctx.focusedAgentId) {
@@ -1044,38 +1113,53 @@ export class InputController {
1044
1113
  return;
1045
1114
  }
1046
1115
 
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;
1116
+ if (text) {
1117
+ const slashResult = await executeBuiltinSlashCommand(text, {
1118
+ ctx: this.ctx,
1119
+ });
1120
+ if (slashResult === true) {
1121
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1122
+ return;
1123
+ }
1124
+ if (typeof slashResult === "string") {
1125
+ // Command handled but returned remaining text to use as prompt.
1126
+ // Record the original slash command text so Up Arrow recalls it.
1127
+ if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
1128
+ text = slashResult;
1129
+ }
1059
1130
  }
1060
1131
 
1061
1132
  // Skill commands invoke through the custom-message path regardless of
1062
1133
  // which keybinding submitted them. Enter routes them as `steer`;
1063
1134
  // Ctrl+Enter (this handler) routes them as `followUp`.
1064
- if (await this.#invokeSkillCommand(text, "followUp")) {
1135
+ if (text && (await this.#invokeSkillCommand(text, "followUp"))) {
1065
1136
  return;
1066
1137
  }
1067
1138
 
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;
1139
+ // Hand the message back on dispatch failure (model/API-key validation,
1140
+ // queue rejection): restore both text AND pending images so an image-only
1141
+ // or text+image draft can be retried, mirroring the main submit error path.
1142
+ const restoreOnError = (error: unknown) => {
1143
+ this.ctx.editor.setText(text);
1144
+ if (images && images.length > 0) {
1145
+ this.ctx.editor.pendingImages = [...images];
1146
+ this.ctx.editor.pendingImageLinks = imageLinks ? [...imageLinks] : images.map(() => undefined);
1147
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
1148
+ }
1149
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1150
+ };
1071
1151
 
1072
1152
  if (this.ctx.session.isStreaming) {
1073
1153
  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
- );
1154
+ try {
1155
+ await this.ctx.withLocalSubmission(
1156
+ text,
1157
+ () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
1158
+ { imageCount: images?.length ?? 0 },
1159
+ );
1160
+ } catch (error) {
1161
+ restoreOnError(error);
1162
+ }
1079
1163
  this.ctx.updatePendingMessagesDisplay();
1080
1164
  this.ctx.ui.requestRender();
1081
1165
  return;
@@ -1083,9 +1167,13 @@ export class InputController {
1083
1167
 
1084
1168
  // Not streaming — just submit normally
1085
1169
  this.ctx.editor.clearDraft(text);
1086
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1087
- imageCount: images?.length ?? 0,
1088
- });
1170
+ try {
1171
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1172
+ imageCount: images?.length ?? 0,
1173
+ });
1174
+ } catch (error) {
1175
+ restoreOnError(error);
1176
+ }
1089
1177
  }
1090
1178
 
1091
1179
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
@@ -1309,33 +1397,65 @@ export class InputController {
1309
1397
  async handleImagePaste(): Promise<boolean> {
1310
1398
  try {
1311
1399
  const image = await this.clipboard.readImage();
1312
- if (!image) {
1313
- // Smart paste (#1628): no image on the clipboard — fall back to
1314
- // pasting its text so the same chord covers both payload kinds.
1315
- // Hosts that pre-empt the terminal's own paste (VS Code's
1316
- // integrated terminal, Win+V clipboard history) deliver only
1317
- // this keypress, so a miss here must not dead-end.
1318
- const text = await this.clipboard.readText();
1319
- if (!text) {
1320
- this.ctx.showStatus("Clipboard is empty");
1321
- return false;
1322
- }
1323
- // Route to the focused component when it accepts pastes (modal
1324
- // Input prompts), matching the enhanced-paste text path (#2127).
1325
- const focused = this.ctx.ui.getFocused();
1326
- const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1327
- target.pasteText(text);
1328
- this.ctx.ui.requestRender();
1400
+ if (image) {
1401
+ return await this.#normalizeAndInsertPastedImage(
1402
+ {
1403
+ type: "image",
1404
+ data: image.data.toBase64(),
1405
+ mimeType: image.mimeType,
1406
+ },
1407
+ `Unsupported clipboard image format: ${image.mimeType}`,
1408
+ );
1409
+ }
1410
+ // #3506: macOS Finder `Cmd+C` puts only a `public.file-url`
1411
+ // representation on the pasteboard. `pbpaste` (the backing call
1412
+ // for `readText` on Darwin) only surfaces plain text / RTF / EPS,
1413
+ // so it returns empty for file-url-only pasteboards — the smart
1414
+ // text fallback below would dead-end with "Clipboard is empty".
1415
+ // Reach the file URL directly via AppleScript and route every
1416
+ // image-shaped path through {@link handleImagePathPaste}, matching
1417
+ // the bracketed-paste handler in `CustomEditor.handleInput` which
1418
+ // iterates every extracted image path. Multi-image Finder
1419
+ // selections must not silently drop after the first attach.
1420
+ // `readMacFileUrls` returns an empty list off Darwin, so the
1421
+ // check is free on every other platform.
1422
+ const fileUrls = (await this.clipboard.readMacFileUrls?.()) ?? [];
1423
+ let attachedFromFileUrls = false;
1424
+ for (const url of fileUrls) {
1425
+ const candidate = extractImagePathFromText(url);
1426
+ if (!candidate) continue;
1427
+ await this.handleImagePathPaste(candidate);
1428
+ attachedFromFileUrls = true;
1429
+ }
1430
+ if (attachedFromFileUrls) return true;
1431
+ // Smart paste (#1628): no image on the clipboard — fall back to
1432
+ // pasting its text so the same chord covers both payload kinds.
1433
+ // Hosts that pre-empt the terminal's own paste (VS Code's
1434
+ // integrated terminal, Win+V clipboard history) deliver only
1435
+ // this keypress, so a miss here must not dead-end.
1436
+ const text = await this.clipboard.readText();
1437
+ if (!text) {
1438
+ this.ctx.showStatus("Clipboard is empty");
1439
+ return false;
1440
+ }
1441
+ // #3506: when the clipboard text is an explicit image file path,
1442
+ // route through {@link handleImagePathPaste} so the image is
1443
+ // loaded and attached instead of pasting the path as literal
1444
+ // text. Covers terminals that paste the Finder file path as
1445
+ // plain text rather than as a `public.file-url` (most macOS
1446
+ // terminals do this for image clipboards).
1447
+ const imagePath = extractImagePathFromText(text);
1448
+ if (imagePath) {
1449
+ await this.handleImagePathPaste(imagePath);
1329
1450
  return true;
1330
1451
  }
1331
- return await this.#normalizeAndInsertPastedImage(
1332
- {
1333
- type: "image",
1334
- data: image.data.toBase64(),
1335
- mimeType: image.mimeType,
1336
- },
1337
- `Unsupported clipboard image format: ${image.mimeType}`,
1338
- );
1452
+ // Route to the focused component when it accepts pastes (modal
1453
+ // Input prompts), matching the enhanced-paste text path (#2127).
1454
+ const focused = this.ctx.ui.getFocused();
1455
+ const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1456
+ target.pasteText(text);
1457
+ this.ctx.ui.requestRender();
1458
+ return true;
1339
1459
  } catch {
1340
1460
  this.ctx.showStatus("Failed to read clipboard");
1341
1461
  return false;
@@ -496,6 +496,7 @@ export class MCPCommandController {
496
496
 
497
497
  try {
498
498
  const oauthResource = oauth.resource ?? finalConfig.url;
499
+ const oauthResourceIsFallback = !oauth.resource;
499
500
  const oauthResult = await this.#handleOAuthFlow(
500
501
  oauth.authorizationUrl,
501
502
  oauth.tokenUrl,
@@ -509,11 +510,13 @@ export class MCPCommandController {
509
510
  prompt: finalConfig.oauth?.prompt,
510
511
  serverUrl: finalConfig.url,
511
512
  resource: oauthResource,
513
+ stripSameOriginResource: oauthResourceIsFallback,
512
514
  },
513
515
  );
514
516
  finalConfig = this.#persistOAuthResult(finalConfig, oauthResult, {
515
517
  tokenUrl: oauth.tokenUrl,
516
518
  resource: oauthResource,
519
+ stripSameOriginResource: oauthResourceIsFallback,
517
520
  clientId: oauth.clientId,
518
521
  userClientSecret: finalConfig.oauth?.clientSecret,
519
522
  });
@@ -583,6 +586,7 @@ export class MCPCommandController {
583
586
  prompt?: string;
584
587
  serverUrl?: string;
585
588
  resource?: string;
589
+ stripSameOriginResource?: boolean;
586
590
  },
587
591
  ): Promise<OAuthFlowResult> {
588
592
  const authStorage = this.ctx.session.modelRegistry.authStorage;
@@ -624,6 +628,7 @@ export class MCPCommandController {
624
628
  callbackPort: opts?.callbackPort,
625
629
  callbackPath: opts?.callbackPath,
626
630
  resource: opts?.resource,
631
+ stripSameOriginResource: opts?.stripSameOriginResource,
627
632
  },
628
633
  {
629
634
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -711,6 +716,7 @@ export class MCPCommandController {
711
716
  clientId: flow.resolvedClientId ?? resolvedClientId,
712
717
  clientSecret: flow.registeredClientSecret ?? resolvedClientSecret,
713
718
  resource: flow.resource,
719
+ authorizationUrl: flow.authorizationUrl,
714
720
  };
715
721
 
716
722
  await authStorage.set(credentialId, oauthCredential);
@@ -751,10 +757,17 @@ export class MCPCommandController {
751
757
  #persistOAuthResult(
752
758
  config: MCPServerConfig,
753
759
  result: OAuthFlowResult,
754
- opts: { tokenUrl: string; resource?: string; clientId?: string; userClientSecret?: string },
760
+ opts: {
761
+ tokenUrl: string;
762
+ resource?: string;
763
+ stripSameOriginResource?: boolean;
764
+ clientId?: string;
765
+ userClientSecret?: string;
766
+ },
755
767
  ): MCPServerConfig {
756
768
  const clientId = result.clientId ?? opts.clientId ?? config.oauth?.clientId;
757
- const resource = result.resource ?? opts.resource ?? config.auth?.resource;
769
+ const resource =
770
+ result.resource ?? (opts.stripSameOriginResource ? undefined : opts.resource) ?? config.auth?.resource;
758
771
  return {
759
772
  ...config,
760
773
  auth: {
@@ -1558,6 +1571,7 @@ export class MCPCommandController {
1558
1571
  const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
1559
1572
  const oauthResource =
1560
1573
  oauth.resource ?? currentAuthResource ?? ("url" in runtimeBaseConfig ? runtimeBaseConfig.url : undefined);
1574
+ const oauthResourceIsFallback = !oauth.resource && !currentAuthResource;
1561
1575
 
1562
1576
  const oauthResult = await this.#handleOAuthFlow(
1563
1577
  oauth.authorizationUrl,
@@ -1572,6 +1586,7 @@ export class MCPCommandController {
1572
1586
  prompt: found.config.oauth?.prompt,
1573
1587
  serverUrl,
1574
1588
  resource: oauthResource,
1589
+ stripSameOriginResource: oauthResourceIsFallback,
1575
1590
  },
1576
1591
  );
1577
1592
 
@@ -1592,6 +1607,7 @@ export class MCPCommandController {
1592
1607
  clientId: oauth.clientId,
1593
1608
  userClientSecret,
1594
1609
  resource: oauthResource,
1610
+ stripSameOriginResource: oauthResourceIsFallback,
1595
1611
  });
1596
1612
  await updateMCPServer(found.filePath, name, updated);
1597
1613
  }
@@ -317,6 +317,24 @@ export class UiHelpers {
317
317
  // updateResult armed.
318
318
  previous.seal();
319
319
  };
320
+ let todoSnapshot: ToolExecutionComponent | null = null;
321
+ const resolveTodoSnapshot = (nextToolName?: string) => {
322
+ const previous = todoSnapshot;
323
+ if (!previous) return;
324
+ if (!previous.isDisplaceableBlock()) {
325
+ todoSnapshot = null;
326
+ return;
327
+ }
328
+ if (previous.canBeDisplacedBy(nextToolName)) {
329
+ todoSnapshot = null;
330
+ this.ctx.chatContainer.removeChild(previous);
331
+ previous.seal();
332
+ return;
333
+ }
334
+ if (nextToolName !== undefined) return;
335
+ todoSnapshot = null;
336
+ previous.seal();
337
+ };
320
338
  const messages = sessionContext.messages;
321
339
  const count = messages.length;
322
340
  for (let i = 0; i < count; i++) {
@@ -477,11 +495,22 @@ export class UiHelpers {
477
495
  component.isDisplaceableBlock()
478
496
  ) {
479
497
  waitingPoll = component;
498
+ } else if (
499
+ message.toolName === "todo" &&
500
+ component instanceof ToolExecutionComponent &&
501
+ component.canBeDisplacedBy("todo")
502
+ ) {
503
+ // A successful todo result supersedes the prior live snapshot. Failed
504
+ // follow-ups return false from canBeDisplacedBy("todo"), so the
505
+ // last-good panel stays on screen.
506
+ resolveTodoSnapshot("todo");
507
+ todoSnapshot = component;
480
508
  }
481
509
  }
482
510
  } else {
483
511
  // A user prompt closes the displacement window, same as the live path.
484
512
  if (message.role === "user") resolveWaitingPoll();
513
+ if (message.role === "user") resolveTodoSnapshot();
485
514
  // All other messages use standard rendering
486
515
  this.ctx.addMessageToChat(message, options);
487
516
  }
@@ -495,6 +524,17 @@ export class UiHelpers {
495
524
  // A trailing waiting poll is final history on rebuild; seal it so it
496
525
  // freezes (and its spinner timer stops) like every other block.
497
526
  resolveWaitingPoll();
527
+ // A trailing todo snapshot is live state, not history: when the rebuild
528
+ // runs mid-turn (settings overlay close, focus attach during streaming),
529
+ // hand it back to the controller so a follow-up `todo` update keeps
530
+ // displacing instead of stacking. Idle rebuilds (resume / compaction)
531
+ // fall through to the seal path so the snapshot freezes as history.
532
+ if (todoSnapshot && this.ctx.session?.isStreaming) {
533
+ this.ctx.eventController?.inheritDisplaceableTodo(todoSnapshot);
534
+ todoSnapshot = null;
535
+ } else {
536
+ resolveTodoSnapshot();
537
+ }
498
538
 
499
539
  this.ctx.pendingTools.clear();
500
540
  this.ctx.ui.requestRender();
@@ -0,0 +1,5 @@
1
+ Automated capture turn — not a user reply. The user has not yet responded to your previous turn. Do not treat this prompt as their answer, as approval to continue, or as acceptance of any pending action; only the user can do that.
2
+
3
+ If your previous turn produced anything reusable, capture it now: a repeatable procedure becomes a managed skill (`manage_skill`); a durable fact, convention, or user preference is worth remembering (`learn`, when memory is enabled). Only capture what will genuinely help next time. If nothing is worth keeping, do nothing.
4
+
5
+ Then stop. Do not run any other tools, do not resume prior work, do not answer your own pending questions, and do not produce a continuation reply. Yield and wait for the user's next prompt.
@@ -1,3 +1,5 @@
1
- Before you finish: if this turn produced anything reusable, capture it now with your learning tools — a repeatable procedure becomes a managed skill (`manage_skill`), and a durable fact or convention is worth remembering (`learn`, when memory is enabled).
1
+ Hidden auto-learn reminder (not a user request). If your previous turn produced anything reusable, capture it now with your learning tools — a repeatable procedure becomes a managed skill (`manage_skill`), and a durable fact, convention, or user preference is worth remembering (`learn`, when memory is enabled).
2
2
 
3
- Only capture what will genuinely help next time. If nothing this turn is worth keeping, do nothing.
3
+ Only capture what will genuinely help next time. If nothing is worth keeping, do nothing.
4
+
5
+ This reminder is appended to the user's real message; answer that message normally — the capture is in addition to, not a replacement for, the work the user just asked for.
@@ -1,6 +1,6 @@
1
1
  **Tasks referenced by verbatim content string, NEVER an auto-generated ID — no "task-1"/"task-N" exists. Pass the content text in the `task` field.**
2
2
 
3
- Next pending task auto-promotes to `in_progress` on each completion.
3
+ On each completion the earliest still-open task (in phase order) auto-promotes to `in_progress`. Completing tasks out of phase order can move this pointer **back** to an earlier phase — that is expected; completed tasks are never reverted.
4
4
 
5
5
  ## Operations
6
6
 
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",