@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.
- package/CHANGELOG.md +24 -0
- package/dist/cli.js +15452 -15142
- package/dist/types/cli/gallery-cli.d.ts +6 -0
- package/dist/types/commands/gallery.d.ts +1 -1
- package/dist/types/config/service-tier.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +36 -33
- package/dist/types/edit/hashline/filesystem.d.ts +1 -18
- package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +4 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +12 -0
- package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
- package/dist/types/internal-urls/types.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +0 -2
- package/dist/types/modes/controllers/input-controller.d.ts +0 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/task/parallel.d.ts +17 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/path-utils.d.ts +3 -0
- package/package.json +13 -13
- package/scripts/build-binary.ts +20 -0
- package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
- package/src/cli/gallery-cli.ts +31 -2
- package/src/cli/gallery-fixtures/agentic.ts +13 -4
- package/src/commands/gallery.ts +11 -3
- package/src/config/service-tier.ts +87 -0
- package/src/config/settings-schema.ts +48 -23
- package/src/edit/hashline/filesystem.ts +14 -0
- package/src/eval/agent-bridge.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3330 -18
- package/src/extensibility/plugins/legacy-pi-compat.ts +29 -14
- package/src/internal-urls/local-protocol.ts +116 -9
- package/src/internal-urls/skill-protocol.ts +3 -3
- package/src/internal-urls/types.ts +3 -0
- package/src/main.ts +1 -0
- package/src/mcp/transports/stdio.ts +5 -0
- package/src/modes/components/custom-editor.test.ts +7 -5
- package/src/modes/components/custom-editor.ts +6 -16
- package/src/modes/controllers/input-controller.ts +143 -137
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +72 -15
- package/src/session/messages.ts +70 -47
- package/src/session/session-history-format.ts +21 -4
- package/src/task/executor.ts +79 -2
- package/src/task/index.ts +11 -6
- package/src/task/parallel.ts +59 -7
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/find.ts +1 -0
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +16 -2
- package/src/tools/path-utils.ts +4 -0
- package/src/tools/read.ts +43 -17
- package/src/tools/search.ts +4 -0
- package/src/utils/shell-snapshot-fn-env.sh +60 -0
- 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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
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
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
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
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
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
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
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:
|
|
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 (
|
|
2858
|
+
if (successfulYieldMessage || this.#yieldTerminationPending) {
|
|
2844
2859
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2845
|
-
if (
|
|
2846
|
-
maintenanceRoute(
|
|
2847
|
-
|
|
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 (
|
|
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?: {
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 {
|