@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.1
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 +26 -0
- package/dist/cli.js +3336 -3318
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +830 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +288 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/chat-transcript-builder.ts +22 -1
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/selector-controller.ts +15 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/sdk.ts +33 -3
- package/src/session/agent-session.ts +239 -17
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- package/src/web/search/providers/codex.ts +68 -21
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
1
|
+
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
3
3
|
import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
|
4
4
|
import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
@@ -32,7 +32,11 @@ import { vocalizer } from "../../tts/vocalizer";
|
|
|
32
32
|
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
33
33
|
import { interruptHint } from "../shared";
|
|
34
34
|
import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
assistantHasVisibleContent,
|
|
37
|
+
assistantUsageIsBilled,
|
|
38
|
+
splitAssistantMessageToolTimeline,
|
|
39
|
+
} from "../utils/transcript-render-helpers";
|
|
36
40
|
import { StreamingRevealController } from "./streaming-reveal";
|
|
37
41
|
import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal";
|
|
38
42
|
|
|
@@ -79,6 +83,8 @@ export class EventController {
|
|
|
79
83
|
#backgroundTaskCallIds = new Set<string>();
|
|
80
84
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
81
85
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
86
|
+
#toolTimelineComponents = new Map<string, Component>();
|
|
87
|
+
#postToolAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
82
88
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
83
89
|
// Assistant component whose turn-ending error is currently mirrored in the
|
|
84
90
|
// pinned banner. Its inline `Error: …` line is suppressed while pinned and
|
|
@@ -254,6 +260,39 @@ export class EventController {
|
|
|
254
260
|
assistantComponent.setToolResultImages(toolCallId, images);
|
|
255
261
|
return true;
|
|
256
262
|
}
|
|
263
|
+
|
|
264
|
+
#insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): boolean {
|
|
265
|
+
const children = this.ctx.chatContainer.children;
|
|
266
|
+
const anchorIndex = anchor ? children.indexOf(anchor) : -1;
|
|
267
|
+
if (anchorIndex < 0) return false;
|
|
268
|
+
if (children.slice(anchorIndex + 1).some(child => !this.ctx.chatContainer.isBlockUncommitted(child))) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
this.ctx.chatContainer.addChild(component);
|
|
272
|
+
children.splice(children.length - 1, 1);
|
|
273
|
+
children.splice(anchorIndex + 1, 0, component);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#upsertPostToolAssistantSegment(
|
|
278
|
+
toolCallId: string,
|
|
279
|
+
segment: AssistantMessage | undefined,
|
|
280
|
+
): AssistantMessageComponent | undefined {
|
|
281
|
+
if (!segment || !assistantHasVisibleContent(segment)) return undefined;
|
|
282
|
+
const existing = this.#postToolAssistantComponents.get(toolCallId);
|
|
283
|
+
if (existing) {
|
|
284
|
+
existing.updateContent(segment);
|
|
285
|
+
return existing;
|
|
286
|
+
}
|
|
287
|
+
const component = createAssistantMessageComponent(this.ctx);
|
|
288
|
+
component.updateContent(segment);
|
|
289
|
+
this.#postToolAssistantComponents.set(toolCallId, component);
|
|
290
|
+
if (!this.#insertAfterTranscriptComponent(this.#toolTimelineComponents.get(toolCallId), component)) {
|
|
291
|
+
this.ctx.chatContainer.addChild(component);
|
|
292
|
+
}
|
|
293
|
+
return component;
|
|
294
|
+
}
|
|
295
|
+
|
|
257
296
|
#updateWorkingMessageFromIntent(intent: unknown): void {
|
|
258
297
|
if (this.ctx.session.isAborting) return;
|
|
259
298
|
// Streamed JSON can deliver non-string `i` (object, number, boolean) before
|
|
@@ -281,6 +320,8 @@ export class EventController {
|
|
|
281
320
|
this.#lastVisibleBlockCount = 0;
|
|
282
321
|
this.#renderedCustomMessages.clear();
|
|
283
322
|
this.#lastIntent = undefined;
|
|
323
|
+
this.#toolTimelineComponents.clear();
|
|
324
|
+
this.#postToolAssistantComponents.clear();
|
|
284
325
|
this.#backgroundTaskCallIds.clear();
|
|
285
326
|
this.#readToolCallArgs.clear();
|
|
286
327
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -365,6 +406,8 @@ export class EventController {
|
|
|
365
406
|
}
|
|
366
407
|
|
|
367
408
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
409
|
+
this.#toolTimelineComponents.clear();
|
|
410
|
+
this.#postToolAssistantComponents.clear();
|
|
368
411
|
this.#lastIntent = undefined;
|
|
369
412
|
this.#readToolCallArgs.clear();
|
|
370
413
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -463,7 +506,10 @@ export class EventController {
|
|
|
463
506
|
this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
|
|
464
507
|
this.ctx.streamingMessage = event.message;
|
|
465
508
|
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|
|
466
|
-
this.#streamingReveal.begin(
|
|
509
|
+
this.#streamingReveal.begin(
|
|
510
|
+
this.ctx.streamingComponent,
|
|
511
|
+
splitAssistantMessageToolTimeline(this.ctx.streamingMessage).beforeTools,
|
|
512
|
+
);
|
|
467
513
|
this.ctx.ui.requestRender();
|
|
468
514
|
}
|
|
469
515
|
}
|
|
@@ -648,7 +694,8 @@ export class EventController {
|
|
|
648
694
|
this.#streamingReveal.resyncVisibility();
|
|
649
695
|
}
|
|
650
696
|
this.ctx.streamingMessage = event.message;
|
|
651
|
-
|
|
697
|
+
const timeline = splitAssistantMessageToolTimeline(this.ctx.streamingMessage);
|
|
698
|
+
this.#streamingReveal.setTarget(timeline.beforeTools);
|
|
652
699
|
|
|
653
700
|
const visibleBlockCount = this.ctx.streamingMessage.content.filter(
|
|
654
701
|
content =>
|
|
@@ -691,6 +738,7 @@ export class EventController {
|
|
|
691
738
|
const group = this.#getReadGroup();
|
|
692
739
|
group.updateArgs(content.arguments, content.id);
|
|
693
740
|
this.ctx.pendingTools.set(content.id, group);
|
|
741
|
+
this.#toolTimelineComponents.set(content.id, group);
|
|
694
742
|
}
|
|
695
743
|
continue;
|
|
696
744
|
}
|
|
@@ -737,6 +785,7 @@ export class EventController {
|
|
|
737
785
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
738
786
|
this.ctx.chatContainer.addChild(component);
|
|
739
787
|
this.ctx.pendingTools.set(content.id, component);
|
|
788
|
+
this.#toolTimelineComponents.set(content.id, component);
|
|
740
789
|
this.#toolArgsReveal.bind(content.id, component);
|
|
741
790
|
} else {
|
|
742
791
|
const component = this.ctx.pendingTools.get(content.id);
|
|
@@ -746,6 +795,9 @@ export class EventController {
|
|
|
746
795
|
}
|
|
747
796
|
}
|
|
748
797
|
}
|
|
798
|
+
for (const [toolCallId, segment] of timeline.afterToolCalls) {
|
|
799
|
+
this.#upsertPostToolAssistantSegment(toolCallId, segment);
|
|
800
|
+
}
|
|
749
801
|
|
|
750
802
|
// Update working message with intent from streamed tool arguments
|
|
751
803
|
for (const content of this.ctx.streamingMessage.content) {
|
|
@@ -810,15 +862,18 @@ export class EventController {
|
|
|
810
862
|
errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt);
|
|
811
863
|
this.ctx.streamingMessage.errorMessage = errorMessage;
|
|
812
864
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
865
|
+
const displayMessage: AssistantMessage =
|
|
866
|
+
silentlyAborted || ttsrSilenced
|
|
867
|
+
? {
|
|
868
|
+
// Silence the streaming render by downgrading stopReason to "stop" for
|
|
869
|
+
// display only — does NOT mutate the persisted message's stopReason
|
|
870
|
+
// (the marker on errorMessage drives replay-side suppression).
|
|
871
|
+
...this.ctx.streamingMessage,
|
|
872
|
+
stopReason: "stop",
|
|
873
|
+
}
|
|
874
|
+
: this.ctx.streamingMessage;
|
|
875
|
+
const displayTimeline = splitAssistantMessageToolTimeline(displayMessage);
|
|
876
|
+
this.ctx.streamingComponent.updateContent(displayTimeline.beforeTools);
|
|
822
877
|
|
|
823
878
|
if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
|
|
824
879
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
@@ -848,8 +903,14 @@ export class EventController {
|
|
|
848
903
|
}
|
|
849
904
|
this.ctx.lastAssistantUsage = usage;
|
|
850
905
|
}
|
|
851
|
-
this
|
|
852
|
-
|
|
906
|
+
this.ctx.streamingComponent.markTranscriptBlockFinalized();
|
|
907
|
+
let lastPostToolAssistantComponent: AssistantMessageComponent | undefined;
|
|
908
|
+
for (const [toolCallId, segment] of displayTimeline.afterToolCalls) {
|
|
909
|
+
const component = this.#upsertPostToolAssistantSegment(toolCallId, segment);
|
|
910
|
+
component?.markTranscriptBlockFinalized();
|
|
911
|
+
if (component) lastPostToolAssistantComponent = component;
|
|
912
|
+
}
|
|
913
|
+
this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
|
|
853
914
|
if (settings.get("display.showTokenUsage") && assistantUsageIsBilled(event.message.usage)) {
|
|
854
915
|
this.ctx.chatContainer.addChild(
|
|
855
916
|
createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
|
|
@@ -886,6 +947,7 @@ export class EventController {
|
|
|
886
947
|
const group = this.#getReadGroup();
|
|
887
948
|
group.updateArgs(event.args, event.toolCallId);
|
|
888
949
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
950
|
+
this.#toolTimelineComponents.set(event.toolCallId, group);
|
|
889
951
|
}
|
|
890
952
|
this.ctx.ui.requestRender();
|
|
891
953
|
return;
|
|
@@ -911,6 +973,7 @@ export class EventController {
|
|
|
911
973
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
912
974
|
this.ctx.chatContainer.addChild(component);
|
|
913
975
|
this.ctx.pendingTools.set(event.toolCallId, component);
|
|
976
|
+
this.#toolTimelineComponents.set(event.toolCallId, component);
|
|
914
977
|
this.ctx.ui.requestRender();
|
|
915
978
|
} else {
|
|
916
979
|
// The tool is about to run, so its arguments are final and validated.
|
|
@@ -1104,6 +1167,8 @@ export class EventController {
|
|
|
1104
1167
|
);
|
|
1105
1168
|
this.#readToolCallArgs.clear();
|
|
1106
1169
|
this.#readToolCallAssistantComponents.clear();
|
|
1170
|
+
this.#toolTimelineComponents.clear();
|
|
1171
|
+
this.#postToolAssistantComponents.clear();
|
|
1107
1172
|
this.#resetReadGroup();
|
|
1108
1173
|
// The turn is over: nothing else lands this turn, so the waiting poll is
|
|
1109
1174
|
// final history — seal it instead of letting its spinner tick while idle.
|
|
@@ -634,7 +634,8 @@ export class SelectorController {
|
|
|
634
634
|
onPick: async (model, selector) => {
|
|
635
635
|
try {
|
|
636
636
|
// Session-only: update agent state but don't persist the model to settings.
|
|
637
|
-
|
|
637
|
+
const roleThinkingLevel = this.ctx.session.resolveTemporaryModelThinkingLevel(model);
|
|
638
|
+
await this.ctx.session.setModelTemporary(model, roleThinkingLevel);
|
|
638
639
|
this.ctx.statusLine.invalidate();
|
|
639
640
|
this.ctx.updateEditorBorderColor();
|
|
640
641
|
const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
|
|
@@ -777,6 +778,7 @@ export class SelectorController {
|
|
|
777
778
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
778
779
|
}
|
|
779
780
|
},
|
|
781
|
+
|
|
780
782
|
onLoginRequest: providerId => {
|
|
781
783
|
done();
|
|
782
784
|
void this.#loginThenReopenModelHub(providerId);
|
|
@@ -1291,7 +1293,7 @@ export class SelectorController {
|
|
|
1291
1293
|
this.ctx.ui.setFocus(dialog);
|
|
1292
1294
|
this.ctx.ui.requestRender();
|
|
1293
1295
|
try {
|
|
1294
|
-
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
|
1296
|
+
const identity = await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
|
1295
1297
|
signal: dialog.signal,
|
|
1296
1298
|
onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
|
|
1297
1299
|
// The dialog renders the full URL (SSH-safe copy target) and
|
|
@@ -1310,8 +1312,18 @@ export class SelectorController {
|
|
|
1310
1312
|
});
|
|
1311
1313
|
this.ctx.session.modelRegistry.refreshInBackground();
|
|
1312
1314
|
const block = new TranscriptBlock();
|
|
1315
|
+
// Name the account (and Anthropic organization) that was stored so a
|
|
1316
|
+
// login that lands on an unintended account/subscription is visible
|
|
1317
|
+
// immediately instead of silently replacing an existing registration.
|
|
1318
|
+
const whoBase = identity?.type === "oauth" ? (identity.email ?? identity.accountId) : undefined;
|
|
1319
|
+
const whoOrg = identity?.type === "oauth" ? (identity.orgName ?? identity.orgId) : undefined;
|
|
1320
|
+
const who = whoBase ? ` as ${whoBase}${whoOrg ? ` (${whoOrg})` : ""}` : whoOrg ? ` as ${whoOrg}` : "";
|
|
1313
1321
|
block.addChild(
|
|
1314
|
-
new Text(
|
|
1322
|
+
new Text(
|
|
1323
|
+
theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}${who}`),
|
|
1324
|
+
1,
|
|
1325
|
+
0,
|
|
1326
|
+
),
|
|
1315
1327
|
);
|
|
1316
1328
|
block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
|
|
1317
1329
|
this.ctx.present(block);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Characters that bind a magic keyword into an identifier or path segment. */
|
|
2
|
+
const LEFT_BOUNDARY = String.raw`(?<![\p{L}\p{N}_./\\-])(?<!::)`;
|
|
3
|
+
|
|
4
|
+
/** Characters that cannot immediately follow a standalone magic keyword. */
|
|
5
|
+
const RIGHT_BOUNDARY = String.raw`(?![\p{L}\p{N}_/\\-])(?!\.[\p{L}\p{N}_-])(?!\()`;
|
|
6
|
+
|
|
7
|
+
/** Escape a literal string for safe insertion into a RegExp source. */
|
|
8
|
+
function escapeRegExp(value: string): string {
|
|
9
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a case-sensitive magic-keyword matcher for prose punctuation boundaries.
|
|
14
|
+
*
|
|
15
|
+
* Sentence punctuation and quotes may touch the keyword, but letters, digits,
|
|
16
|
+
* underscores, slashes, backslashes, hyphens, file-extension dots, symbol
|
|
17
|
+
* references (`foo::keyword`), and immediate call parentheses (`keyword()`)
|
|
18
|
+
* keep the occurrence embedded in code rather than prose.
|
|
19
|
+
*/
|
|
20
|
+
export function magicKeywordRegex(keyword: string, flags = ""): RegExp {
|
|
21
|
+
const normalizedFlags = flags.includes("u") ? flags : `${flags}u`;
|
|
22
|
+
return new RegExp(`${LEFT_BOUNDARY}${escapeRegExp(keyword)}${RIGHT_BOUNDARY}`, normalizedFlags);
|
|
23
|
+
}
|
package/src/modes/orchestrate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import orchestrateNotice from "../prompts/system/orchestrate-notice.md" with { type: "text" };
|
|
2
2
|
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
|
3
|
+
import { magicKeywordRegex } from "./magic-keyword-boundary";
|
|
3
4
|
import { keywordInProse } from "./markdown-prose";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -8,21 +9,21 @@ import { keywordInProse } from "./markdown-prose";
|
|
|
8
9
|
* Typing the standalone word in the input editor paints it with a cool
|
|
9
10
|
* teal→violet gradient ({@link highlightOrchestrate}); submitting a message that
|
|
10
11
|
* mentions it appends a hidden {@link ORCHESTRATE_NOTICE} that switches the model
|
|
11
|
-
* into multi-agent orchestration mode. Matching is
|
|
12
|
+
* into multi-agent orchestration mode. Matching is prose-delimited and
|
|
12
13
|
* case-sensitive (lowercase only), so "orchestrated", "Orchestrate", or a path
|
|
13
14
|
* like "orchestrate.ts" never trigger either behavior. Replaces the former
|
|
14
15
|
* `/orchestrate` slash command.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
// Detection: lowercase keyword flanked by whitespace or a string edge.
|
|
18
|
-
const ORCHESTRATE_WORD =
|
|
18
|
+
// Detection: lowercase keyword flanked by prose punctuation, whitespace, or a string edge.
|
|
19
|
+
const ORCHESTRATE_WORD = magicKeywordRegex("orchestrate");
|
|
19
20
|
|
|
20
21
|
/** Hidden system notice appended after a user message that mentions "orchestrate". */
|
|
21
22
|
export const ORCHESTRATE_NOTICE: string = orchestrateNotice.trim();
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Whether `text` contains the standalone keyword "orchestrate" (lowercase,
|
|
25
|
-
*
|
|
26
|
+
* prose-delimited) in prose — never inside a code block, inline code span,
|
|
26
27
|
* or XML/HTML section.
|
|
27
28
|
*/
|
|
28
29
|
export function containsOrchestrate(text: string): boolean {
|
|
@@ -36,7 +37,7 @@ export function containsOrchestrate(text: string): boolean {
|
|
|
36
37
|
*/
|
|
37
38
|
export const highlightOrchestrate: KeywordHighlighter = createGradientHighlighter({
|
|
38
39
|
probe: /orchestrate/,
|
|
39
|
-
highlight:
|
|
40
|
+
highlight: magicKeywordRegex("orchestrate", "g"),
|
|
40
41
|
stops: 14,
|
|
41
42
|
hue: t => 150 + t * 130,
|
|
42
43
|
});
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -111,13 +111,22 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
let wroteTextWorkingIndicator = false;
|
|
115
|
+
const writeTextWorkingIndicator = (): void => {
|
|
116
|
+
if (mode !== "text" || wroteTextWorkingIndicator) return;
|
|
117
|
+
process.stderr.write("Working...\n");
|
|
118
|
+
wroteTextWorkingIndicator = true;
|
|
119
|
+
};
|
|
120
|
+
|
|
114
121
|
// Send initial message with attachments
|
|
115
122
|
if (initialMessage !== undefined) {
|
|
123
|
+
writeTextWorkingIndicator();
|
|
116
124
|
await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages }));
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
// Send remaining messages
|
|
120
128
|
for (const message of messages) {
|
|
129
|
+
writeTextWorkingIndicator();
|
|
121
130
|
await logger.time("print:prompt:next", () => session.prompt(message));
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -75,6 +75,7 @@ export class RpcHostToolBridge {
|
|
|
75
75
|
#output: RpcHostToolOutput;
|
|
76
76
|
#definitions = new Map<string, RpcHostToolDefinition>();
|
|
77
77
|
#pendingCalls = new Map<string, PendingHostToolCall>();
|
|
78
|
+
#closedError: Error | undefined;
|
|
78
79
|
|
|
79
80
|
constructor(output: RpcHostToolOutput) {
|
|
80
81
|
this.#output = output;
|
|
@@ -126,6 +127,10 @@ export class RpcHostToolBridge {
|
|
|
126
127
|
return Promise.reject(new Error(`Host tool "${definition.name}" was aborted`));
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
if (this.#closedError) {
|
|
131
|
+
return Promise.reject(this.#closedError);
|
|
132
|
+
}
|
|
133
|
+
|
|
129
134
|
const id = Snowflake.next() as string;
|
|
130
135
|
const { promise, resolve, reject } = Promise.withResolvers<AgentToolResult<unknown>>();
|
|
131
136
|
let settled = false;
|
|
@@ -183,4 +188,14 @@ export class RpcHostToolBridge {
|
|
|
183
188
|
pending.reject(error);
|
|
184
189
|
}
|
|
185
190
|
}
|
|
191
|
+
|
|
192
|
+
/** Reject active and future host tool requests after the RPC client disconnects. */
|
|
193
|
+
close(message: string): void {
|
|
194
|
+
if (!this.#closedError) this.#closedError = new Error(message);
|
|
195
|
+
const pendingCalls = Array.from(this.#pendingCalls.values());
|
|
196
|
+
this.#pendingCalls.clear();
|
|
197
|
+
for (const pending of pendingCalls) {
|
|
198
|
+
pending.reject(this.#closedError);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
186
201
|
}
|
|
@@ -59,6 +59,29 @@ export type PendingExtensionRequest = {
|
|
|
59
59
|
reject: (error: Error) => void;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
/** Pending extension UI request map that can fail closed when the RPC client disconnects. */
|
|
63
|
+
export class RpcPendingExtensionRequests extends Map<string, PendingExtensionRequest> {
|
|
64
|
+
#closedError: Error | undefined;
|
|
65
|
+
|
|
66
|
+
override set(id: string, request: PendingExtensionRequest): this {
|
|
67
|
+
if (this.#closedError) {
|
|
68
|
+
request.reject(this.#closedError);
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
return super.set(id, request);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reject every active and future extension UI request. */
|
|
75
|
+
rejectAll(message: string): void {
|
|
76
|
+
if (!this.#closedError) this.#closedError = new Error(message);
|
|
77
|
+
const requests = Array.from(this.values());
|
|
78
|
+
this.clear();
|
|
79
|
+
for (const request of requests) {
|
|
80
|
+
request.reject(this.#closedError);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
type RpcOutput = (
|
|
63
86
|
obj:
|
|
64
87
|
| RpcResponse
|
|
@@ -246,45 +269,49 @@ function isRpcExtensionUIResponse(value: unknown): value is RpcExtensionUIRespon
|
|
|
246
269
|
return value.type === "extension_ui_response" && typeof value.id === "string";
|
|
247
270
|
}
|
|
248
271
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
*
|
|
252
|
-
* Bash commands are dispatched in the background so the caller (the stdin loop
|
|
253
|
-
* in {@link runRpcMode}) can keep reading subsequent frames while a shell
|
|
254
|
-
* command is still running. This lets a client send `abort_bash` (or any other
|
|
255
|
-
* command) while a long-running `bash` is in flight. Response correlation is
|
|
256
|
-
* preserved via each command's `id`; ordering across concurrent commands is
|
|
257
|
-
* not guaranteed and clients MUST match on `id`.
|
|
258
|
-
*
|
|
259
|
-
* @returns `undefined` when the frame was routed to a side-channel handler
|
|
260
|
-
* (extension UI response, host tool/URI frames) or dispatched in the
|
|
261
|
-
* background (`bash`). Otherwise a promise that resolves once the response
|
|
262
|
-
* for the command has been emitted via `output`. Errors from `handleCommand`
|
|
263
|
-
* on non-`bash` commands propagate; the caller is expected to wrap them.
|
|
264
|
-
*/
|
|
265
|
-
export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
|
|
266
|
-
// Side-channel: extension UI responses resolve a pending dialog promise.
|
|
272
|
+
/** Dispatch side-channel frames that must overtake the serialized command queue. */
|
|
273
|
+
export function dispatchRpcControlFrame(parsed: unknown, deps: RpcInputFrameDeps): boolean {
|
|
267
274
|
if (isRpcExtensionUIResponse(parsed)) {
|
|
268
275
|
const pending = deps.pendingExtensionRequests.get(parsed.id);
|
|
269
276
|
if (pending) pending.resolve(parsed);
|
|
270
|
-
return
|
|
277
|
+
return true;
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
if (isRpcHostToolResult(parsed)) {
|
|
274
281
|
deps.onHostToolResult(parsed);
|
|
275
|
-
return
|
|
282
|
+
return true;
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
if (isRpcHostToolUpdate(parsed)) {
|
|
279
286
|
deps.onHostToolUpdate(parsed);
|
|
280
|
-
return
|
|
287
|
+
return true;
|
|
281
288
|
}
|
|
282
289
|
|
|
283
290
|
if (isRpcHostUriResult(parsed)) {
|
|
284
291
|
deps.onHostUriResult(parsed);
|
|
285
|
-
return
|
|
292
|
+
return true;
|
|
286
293
|
}
|
|
287
294
|
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Dispatch a single parsed frame from the RPC input stream.
|
|
300
|
+
*
|
|
301
|
+
* Bash commands are dispatched in the background so the caller can keep reading
|
|
302
|
+
* subsequent frames while a shell command is still running. This lets a client
|
|
303
|
+
* send `abort_bash` while a long-running `bash` is in flight. Response
|
|
304
|
+
* correlation is preserved via each command's `id`; ordering across concurrent
|
|
305
|
+
* commands is not guaranteed and clients MUST match on `id`.
|
|
306
|
+
*
|
|
307
|
+
* @returns `undefined` when the frame was routed to a side-channel handler
|
|
308
|
+
* (extension UI response, host tool/URI frames) or dispatched in the
|
|
309
|
+
* background (`bash`). Otherwise a promise that resolves once the response
|
|
310
|
+
* for the command has been emitted via `output`. Errors from `handleCommand`
|
|
311
|
+
* on non-`bash` commands propagate; the caller is expected to wrap them.
|
|
312
|
+
*/
|
|
313
|
+
export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
|
|
314
|
+
if (dispatchRpcControlFrame(parsed, deps)) return undefined;
|
|
288
315
|
// Regular RPC command. The transport contract states each remaining frame
|
|
289
316
|
// is an {@link RpcCommand}; `handleCommand`'s `default` arm surfaces
|
|
290
317
|
// unknown discriminants as an error response, so we do not shape-check
|
|
@@ -313,6 +340,64 @@ export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps):
|
|
|
313
340
|
})();
|
|
314
341
|
}
|
|
315
342
|
|
|
343
|
+
/** Serializes ordinary RPC commands while allowing control frames to dispatch immediately. */
|
|
344
|
+
export class RpcInputDispatcher {
|
|
345
|
+
#tail: Promise<void> = Promise.resolve();
|
|
346
|
+
#tasks = new Set<Promise<void>>();
|
|
347
|
+
readonly #deps: RpcInputFrameDeps;
|
|
348
|
+
readonly #afterSerialCommand: (() => Promise<void>) | undefined;
|
|
349
|
+
|
|
350
|
+
constructor(options: { deps: RpcInputFrameDeps; afterSerialCommand?: () => Promise<void> }) {
|
|
351
|
+
this.#deps = options.deps;
|
|
352
|
+
this.#afterSerialCommand = options.afterSerialCommand;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Accept a parsed input frame without blocking the stdin reader. */
|
|
356
|
+
dispatch(parsed: unknown): void {
|
|
357
|
+
try {
|
|
358
|
+
if (dispatchRpcControlFrame(parsed, this.#deps)) return;
|
|
359
|
+
|
|
360
|
+
const command = parsed as RpcCommand;
|
|
361
|
+
if (command.type === "bash") {
|
|
362
|
+
dispatchRpcInputFrame(command, this.#deps);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const task = this.#tail.then(
|
|
367
|
+
() => this.#dispatchSerialCommand(command),
|
|
368
|
+
() => this.#dispatchSerialCommand(command),
|
|
369
|
+
);
|
|
370
|
+
this.#tail = task.catch(() => {});
|
|
371
|
+
this.#tasks.add(task);
|
|
372
|
+
void task.finally(() => {
|
|
373
|
+
this.#tasks.delete(task);
|
|
374
|
+
});
|
|
375
|
+
} catch (err: unknown) {
|
|
376
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
377
|
+
this.#deps.output(this.#deps.errorResponse(undefined, "parse", `Failed to parse command: ${message}`));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Await every accepted serial command, including commands queued before EOF. */
|
|
382
|
+
async drain(): Promise<void> {
|
|
383
|
+
while (this.#tasks.size > 0) {
|
|
384
|
+
await Promise.allSettled(Array.from(this.#tasks));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async #dispatchSerialCommand(command: RpcCommand): Promise<void> {
|
|
389
|
+
try {
|
|
390
|
+
const awaited = dispatchRpcInputFrame(command, this.#deps);
|
|
391
|
+
if (awaited) await awaited;
|
|
392
|
+
} catch (err: unknown) {
|
|
393
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
394
|
+
this.#deps.output(this.#deps.errorResponse(command.id, command.type, message));
|
|
395
|
+
} finally {
|
|
396
|
+
await this.#afterSerialCommand?.();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
316
401
|
/**
|
|
317
402
|
* Coordinates deferred shutdown with in-flight background input tasks.
|
|
318
403
|
*
|
|
@@ -551,7 +636,7 @@ export async function runRpcMode(
|
|
|
551
636
|
|
|
552
637
|
const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
|
|
553
638
|
|
|
554
|
-
const pendingExtensionRequests = new
|
|
639
|
+
const pendingExtensionRequests = new RpcPendingExtensionRequests();
|
|
555
640
|
const hostToolBridge = new RpcHostToolBridge(output);
|
|
556
641
|
const hostUriBridge = new RpcHostUriBridge(output);
|
|
557
642
|
const subagentRegistry = eventBus ? new RpcSubagentRegistry(eventBus, output) : undefined;
|
|
@@ -1279,35 +1364,25 @@ export async function runRpcMode(
|
|
|
1279
1364
|
onHostUriResult: frame => hostUriBridge.handleResult(frame),
|
|
1280
1365
|
};
|
|
1281
1366
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1367
|
+
const inputDispatcher = new RpcInputDispatcher({
|
|
1368
|
+
deps: dispatchFrameDeps,
|
|
1369
|
+
afterSerialCommand: () => shutdownCoordinator.checkShutdownRequested(),
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// Keep the stdin reader moving: side-channel frames dispatch immediately,
|
|
1373
|
+
// ordinary commands serialize through inputDispatcher, and bash remains
|
|
1374
|
+
// background-dispatched so abort_bash can overtake it.
|
|
1285
1375
|
for await (const parsed of readJsonl(Bun.stdin.stream())) {
|
|
1286
|
-
|
|
1287
|
-
const awaited = dispatchRpcInputFrame(parsed, dispatchFrameDeps);
|
|
1288
|
-
if (awaited) {
|
|
1289
|
-
await awaited;
|
|
1290
|
-
// Check for deferred shutdown request (idle between commands).
|
|
1291
|
-
// Background-dispatched bash frames skip this check so a later
|
|
1292
|
-
// abort_bash can still be read; the coordinator re-checks when
|
|
1293
|
-
// each tracked task settles, so a shutdown requested mid-bash
|
|
1294
|
-
// fires once the response frame is written even if no further
|
|
1295
|
-
// client frames arrive.
|
|
1296
|
-
await shutdownCoordinator.checkShutdownRequested();
|
|
1297
|
-
}
|
|
1298
|
-
} catch (e: unknown) {
|
|
1299
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1300
|
-
output(error(undefined, "parse", `Failed to parse command: ${message}`));
|
|
1301
|
-
}
|
|
1376
|
+
inputDispatcher.dispatch(parsed);
|
|
1302
1377
|
}
|
|
1303
1378
|
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
// stdin closed — RPC client is gone, exit cleanly
|
|
1309
|
-
hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
|
|
1379
|
+
// stdin closed — RPC client is gone. Fail pending side-channel requests
|
|
1380
|
+
// first so active/queued commands can settle, then drain accepted work.
|
|
1381
|
+
pendingExtensionRequests.rejectAll("RPC client disconnected before extension UI response completed");
|
|
1382
|
+
hostToolBridge.close("RPC client disconnected before host tool execution completed");
|
|
1310
1383
|
hostUriBridge.clear("RPC client disconnected before host URI request completed");
|
|
1384
|
+
await inputDispatcher.drain();
|
|
1385
|
+
await shutdownCoordinator.drain();
|
|
1311
1386
|
subagentRegistry?.dispose();
|
|
1312
1387
|
process.exit(0);
|
|
1313
1388
|
}
|
package/src/modes/ultrathink.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
|
|
2
2
|
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
|
3
|
+
import { magicKeywordRegex } from "./magic-keyword-boundary";
|
|
3
4
|
import { keywordInProse } from "./markdown-prose";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -8,20 +9,20 @@ import { keywordInProse } from "./markdown-prose";
|
|
|
8
9
|
* Typing the standalone word in the input editor paints it with a rainbow
|
|
9
10
|
* gradient ({@link highlightUltrathink}); submitting a message that mentions it
|
|
10
11
|
* appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
|
|
11
|
-
* multi-step reasoning. Matching is
|
|
12
|
+
* multi-step reasoning. Matching is prose-delimited and case-sensitive
|
|
12
13
|
* (lowercase only), so "ultrathinking", "Ultrathink", or "ultrathink.ts" never
|
|
13
14
|
* trigger either behavior.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
// Detection: lowercase keyword flanked by whitespace or a string edge.
|
|
17
|
-
const ULTRATHINK_WORD =
|
|
17
|
+
// Detection: lowercase keyword flanked by prose punctuation, whitespace, or a string edge.
|
|
18
|
+
const ULTRATHINK_WORD = magicKeywordRegex("ultrathink");
|
|
18
19
|
|
|
19
20
|
/** Hidden system notice appended after a user message that mentions "ultrathink". */
|
|
20
21
|
export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Whether `text` contains the standalone keyword "ultrathink" (lowercase,
|
|
24
|
-
*
|
|
25
|
+
* prose-delimited) in prose — never inside a code block, inline code span,
|
|
25
26
|
* or XML/HTML section.
|
|
26
27
|
*/
|
|
27
28
|
export function containsUltrathink(text: string): boolean {
|
|
@@ -35,7 +36,7 @@ export function containsUltrathink(text: string): boolean {
|
|
|
35
36
|
*/
|
|
36
37
|
export const highlightUltrathink: KeywordHighlighter = createGradientHighlighter({
|
|
37
38
|
probe: /ultrathink/,
|
|
38
|
-
highlight:
|
|
39
|
+
highlight: magicKeywordRegex("ultrathink", "g"),
|
|
39
40
|
stops: 14,
|
|
40
41
|
hue: t => t * 330,
|
|
41
42
|
});
|