@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.
Files changed (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. 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 { assistantUsageIsBilled } from "../utils/transcript-render-helpers";
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(this.ctx.streamingComponent, this.ctx.streamingMessage);
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
- this.#streamingReveal.setTarget(this.ctx.streamingMessage);
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
- if (silentlyAborted || ttsrSilenced) {
814
- // Silence the streaming render by downgrading stopReason to "stop" for
815
- // display only — does NOT mutate the persisted message's stopReason
816
- // (the marker on errorMessage drives replay-side suppression).
817
- const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
818
- this.ctx.streamingComponent.updateContent(msgWithoutAbort);
819
- } else {
820
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
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.#lastAssistantComponent = this.ctx.streamingComponent;
852
- this.#lastAssistantComponent.markTranscriptBlockFinalized();
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
- await this.ctx.session.setModelTemporary(model);
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(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
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
+ }
@@ -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 whitespace-delimited and
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. Non-global so `.test` stays stateless.
18
- const ORCHESTRATE_WORD = /(?<!\S)orchestrate(?!\S)/;
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
- * whitespace-delimited) in prose — never inside a code block, inline code span,
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: /(?<!\S)orchestrate(?!\S)/g,
40
+ highlight: magicKeywordRegex("orchestrate", "g"),
40
41
  stops: 14,
41
42
  hue: t => 150 + t * 130,
42
43
  });
@@ -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
- * Dispatch a single parsed frame from the RPC input stream.
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 undefined;
277
+ return true;
271
278
  }
272
279
 
273
280
  if (isRpcHostToolResult(parsed)) {
274
281
  deps.onHostToolResult(parsed);
275
- return undefined;
282
+ return true;
276
283
  }
277
284
 
278
285
  if (isRpcHostToolUpdate(parsed)) {
279
286
  deps.onHostToolUpdate(parsed);
280
- return undefined;
287
+ return true;
281
288
  }
282
289
 
283
290
  if (isRpcHostUriResult(parsed)) {
284
291
  deps.onHostUriResult(parsed);
285
- return undefined;
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 Map<string, PendingExtensionRequest>();
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
- // Listen for JSON input using Bun's stdin. Frame dispatch lives in
1283
- // dispatchRpcInputFrame so it can be exercised directly by tests; see the
1284
- // helper's docstring for the concurrency contract.
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
- try {
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
- // Background bash tasks may still owe response frames; drain them before
1305
- // tearing down (stdin EOF ends the frame stream, not in-flight work).
1306
- await shutdownCoordinator.drain();
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
  }
@@ -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 whitespace-delimited and case-sensitive
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. Non-global so `.test` stays stateless.
17
- const ULTRATHINK_WORD = /(?<!\S)ultrathink(?!\S)/;
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
- * whitespace-delimited) in prose — never inside a code block, inline code span,
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: /(?<!\S)ultrathink(?!\S)/g,
39
+ highlight: magicKeywordRegex("ultrathink", "g"),
39
40
  stops: 14,
40
41
  hue: t => t * 330,
41
42
  });