jeopi-cli 16.4.2 → 16.4.4

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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
@@ -3,12 +3,14 @@ import * as path from "node:path";
3
3
  import type { AssistantMessage } from "jeopi-ai";
4
4
  import { prompt, Snowflake } from "jeopi-utils";
5
5
  import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" };
6
+ import tanContextSwitchPrompt from "../../prompts/system/tan-context-switch.md" with { type: "text" };
6
7
  import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
7
8
  import * as sdk from "../../sdk";
8
9
  import type { AgentSession } from "../../session/agent-session";
9
10
  import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages";
10
11
  import { SessionManager } from "../../session/session-manager";
11
12
  import { createMCPProxyTools, createSubagentSettings } from "../../task/executor";
13
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo";
12
14
  import type { InteractiveModeContext } from "../types";
13
15
 
14
16
  const TAN_LABEL_PREVIEW_LENGTH = 80;
@@ -66,6 +68,11 @@ export class TanCommandController {
66
68
  }
67
69
 
68
70
  const parentSessionId = session.sessionId;
71
+ // Providers route on `promptCacheKey ?? sessionId`, so the parent's live
72
+ // requests may cache under a pinned key that differs from its session id
73
+ // (the parent being itself a fork/tan). Mirror exactly what the parent
74
+ // populated the cache under — same rule as advisor and handoff calls.
75
+ const parentPromptCacheKey = session.agent.promptCacheKey ?? parentSessionId;
69
76
  const thinkingLevel = session.configuredThinkingLevel();
70
77
  const systemPrompt = [...session.systemPrompt];
71
78
  const toolNames = session.getActiveToolNames();
@@ -111,7 +118,7 @@ export class TanCommandController {
111
118
  systemPrompt,
112
119
  toolNames,
113
120
  providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`,
114
- providerPromptCacheKey: parentSessionId,
121
+ providerPromptCacheKey: parentPromptCacheKey,
115
122
  modelRegistry,
116
123
  authStorage: modelRegistry.authStorage,
117
124
  settings,
@@ -127,19 +134,51 @@ export class TanCommandController {
127
134
  disableExtensionDiscovery: true,
128
135
  });
129
136
  clone = created.session;
137
+ clone.sessionManager.appendSessionInit({
138
+ systemPrompt: clone.systemPrompt.join("\n\n"),
139
+ task: trimmedWork,
140
+ tools: clone.getActiveToolNames(),
141
+ });
130
142
  const abortClone = () => {
131
143
  void clone?.abort();
132
144
  };
133
145
  signal.addEventListener("abort", abortClone, { once: true });
146
+ // The fork inherits the parent's todo list via session entries;
147
+ // its reminders would drag the tan back onto the parent's task.
148
+ // Clear runtime state and persist an empty edit so reloads agree.
149
+ clone.setTodoPhases([]);
150
+ cloneManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: [] });
151
+ const injectContextSwitch = () => {
152
+ clone?.agent.appendMessage({
153
+ role: "developer",
154
+ content: tanContextSwitchPrompt,
155
+ attribution: "agent",
156
+ timestamp: Date.now(),
157
+ });
158
+ };
159
+ // Compaction summarizes the fork notice away with the rest of the
160
+ // history, after which the clone re-adopts the parent's task as its
161
+ // own (the summary blends both). Re-inject after every successful
162
+ // compaction so the fork boundary survives summarization.
163
+ const unsubscribeCompaction = clone.subscribe(event => {
164
+ if (event.type === "auto_compaction_end" && event.result && !event.aborted) {
165
+ injectContextSwitch();
166
+ }
167
+ });
134
168
  try {
135
169
  if (signal.aborted) {
136
170
  abortClone();
137
171
  throw new Error("Aborted before execution");
138
172
  }
173
+ // Inject a context-switch developer message so the clone knows
174
+ // it is a tangential fork — its parent owns the prior conversation;
175
+ // this agent must focus exclusively on the user's request.
176
+ injectContextSwitch();
139
177
  await clone.prompt(trimmedWork, { attribution: "user" });
140
178
  await clone.waitForIdle();
141
179
  return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)";
142
180
  } finally {
181
+ unsubscribeCompaction();
143
182
  signal.removeEventListener("abort", abortClone);
144
183
  }
145
184
  } finally {
@@ -118,6 +118,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo
118
118
  import { ToolError } from "../tools/tool-errors";
119
119
  import { vocalizer } from "../tts/vocalizer";
120
120
  import { renderTreeList } from "../tui/tree-list";
121
+ import { copyToClipboard } from "../utils/clipboard";
121
122
  import type { EventBus } from "../utils/event-bus";
122
123
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
123
124
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -2391,6 +2392,7 @@ export class InteractiveMode implements InteractiveModeContext {
2391
2392
  {
2392
2393
  onPick: choice => finish(choice),
2393
2394
  onCancel: () => finish(undefined),
2395
+ onCopyPlan: content => void this.#copyPlanToClipboard(content),
2394
2396
  onExternalEditor: dialogOptions?.onExternalEditor,
2395
2397
  onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
2396
2398
  onPlanEdited: dialogOptions?.onPlanEdited,
@@ -2457,6 +2459,17 @@ export class InteractiveMode implements InteractiveModeContext {
2457
2459
  return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
2458
2460
  }
2459
2461
 
2462
+ async #copyPlanToClipboard(content: string): Promise<void> {
2463
+ try {
2464
+ await copyToClipboard(content);
2465
+ this.showStatus("Copied plan to clipboard");
2466
+ } catch (error) {
2467
+ this.showWarning(
2468
+ `Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`,
2469
+ );
2470
+ }
2471
+ }
2472
+
2460
2473
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
2461
2474
  const editorCmd = getEditorCommand();
2462
2475
  if (!editorCmd) {
@@ -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
  });
@@ -62,13 +62,22 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
62
62
  }
63
63
  });
64
64
 
65
+ let wroteTextWorkingIndicator = false;
66
+ const writeTextWorkingIndicator = (): void => {
67
+ if (mode !== "text" || wroteTextWorkingIndicator) return;
68
+ process.stderr.write("Working...\n");
69
+ wroteTextWorkingIndicator = true;
70
+ };
71
+
65
72
  // Send initial message with attachments
66
73
  if (initialMessage !== undefined) {
74
+ writeTextWorkingIndicator();
67
75
  await logger.time("print:prompt:initial", () => session.prompt(initialMessage, { images: initialImages }));
68
76
  }
69
77
 
70
78
  // Send remaining messages
71
79
  for (const message of messages) {
80
+ writeTextWorkingIndicator();
72
81
  await logger.time("print:prompt:next", () => session.prompt(message));
73
82
  }
74
83
 
@@ -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;
@@ -125,6 +126,9 @@ export class RpcHostToolBridge {
125
126
  if (signal?.aborted) {
126
127
  return Promise.reject(new Error(`Host tool "${definition.name}" was aborted`));
127
128
  }
129
+ if (this.#closedError) {
130
+ return Promise.reject(this.#closedError);
131
+ }
128
132
 
129
133
  const id = Snowflake.next() as string;
130
134
  const { promise, resolve, reject } = Promise.withResolvers<AgentToolResult<unknown>>();
@@ -183,4 +187,13 @@ export class RpcHostToolBridge {
183
187
  pending.reject(error);
184
188
  }
185
189
  }
190
+ /** Reject active and future host tool requests after the RPC client disconnects. */
191
+ close(message: string): void {
192
+ if (!this.#closedError) this.#closedError = new Error(message);
193
+ const pendingCalls = Array.from(this.#pendingCalls.values());
194
+ this.#pendingCalls.clear();
195
+ for (const pending of pendingCalls) {
196
+ pending.reject(this.#closedError);
197
+ }
198
+ }
186
199
  }
@@ -58,6 +58,28 @@ export type PendingExtensionRequest = {
58
58
  resolve: (response: RpcExtensionUIResponse) => void;
59
59
  reject: (error: Error) => void;
60
60
  };
61
+ /** Pending extension UI request map that can fail closed when the RPC client disconnects. */
62
+ export class RpcPendingExtensionRequests extends Map<string, PendingExtensionRequest> {
63
+ #closedError: Error | undefined;
64
+
65
+ override set(id: string, request: PendingExtensionRequest): this {
66
+ if (this.#closedError) {
67
+ request.reject(this.#closedError);
68
+ return this;
69
+ }
70
+ return super.set(id, request);
71
+ }
72
+
73
+ /** Reject every active and future extension UI request. */
74
+ rejectAll(message: string): void {
75
+ if (!this.#closedError) this.#closedError = new Error(message);
76
+ const requests = Array.from(this.values());
77
+ this.clear();
78
+ for (const request of requests) {
79
+ request.reject(this.#closedError);
80
+ }
81
+ }
82
+ }
61
83
 
62
84
  type RpcOutput = (
63
85
  obj:
@@ -88,6 +110,7 @@ export type RpcSkillCommandResult = { agentInvoked: true };
88
110
  export async function tryRunRpcSkillCommand(
89
111
  session: RpcSkillCommandSession,
90
112
  text: string,
113
+ streamingBehavior: "steer" | "followUp" = "steer",
91
114
  ): Promise<RpcSkillCommandResult | false> {
92
115
  if (!session.skillsSettings?.enableSkillCommands) return false;
93
116
  const parsed = parseSkillInvocation(text);
@@ -95,13 +118,16 @@ export async function tryRunRpcSkillCommand(
95
118
  const skill = session.skills.find(candidate => candidate.name === parsed.name);
96
119
  if (!skill) return false;
97
120
  const built = await buildSkillPromptMessage(skill, parsed.args, "user");
98
- await session.promptCustomMessage({
99
- customType: SKILL_PROMPT_MESSAGE_TYPE,
100
- content: built.message,
101
- display: true,
102
- details: built.details,
103
- attribution: "user",
104
- });
121
+ await session.promptCustomMessage(
122
+ {
123
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
124
+ content: built.message,
125
+ display: true,
126
+ details: built.details,
127
+ attribution: "user",
128
+ },
129
+ { streamingBehavior },
130
+ );
105
131
  return { agentInvoked: true };
106
132
  }
107
133
 
@@ -242,45 +268,50 @@ function isRpcExtensionUIResponse(value: unknown): value is RpcExtensionUIRespon
242
268
  return value.type === "extension_ui_response" && typeof value.id === "string";
243
269
  }
244
270
 
245
- /**
246
- * Dispatch a single parsed frame from the RPC input stream.
247
- *
248
- * Bash commands are dispatched in the background so the caller (the stdin loop
249
- * in {@link runRpcMode}) can keep reading subsequent frames while a shell
250
- * command is still running. This lets a client send `abort_bash` (or any other
251
- * command) while a long-running `bash` is in flight. Response correlation is
252
- * preserved via each command's `id`; ordering across concurrent commands is
253
- * not guaranteed and clients MUST match on `id`.
254
- *
255
- * @returns `undefined` when the frame was routed to a side-channel handler
256
- * (extension UI response, host tool/URI frames) or dispatched in the
257
- * background (`bash`). Otherwise a promise that resolves once the response
258
- * for the command has been emitted via `output`. Errors from `handleCommand`
259
- * on non-`bash` commands propagate; the caller is expected to wrap them.
260
- */
261
- export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
262
- // Side-channel: extension UI responses resolve a pending dialog promise.
271
+ /** Dispatch side-channel frames that must overtake the serialized command queue. */
272
+ export function dispatchRpcControlFrame(parsed: unknown, deps: RpcInputFrameDeps): boolean {
263
273
  if (isRpcExtensionUIResponse(parsed)) {
264
274
  const pending = deps.pendingExtensionRequests.get(parsed.id);
265
275
  if (pending) pending.resolve(parsed);
266
- return undefined;
276
+ return true;
267
277
  }
268
278
 
269
279
  if (isRpcHostToolResult(parsed)) {
270
280
  deps.onHostToolResult(parsed);
271
- return undefined;
281
+ return true;
272
282
  }
273
283
 
274
284
  if (isRpcHostToolUpdate(parsed)) {
275
285
  deps.onHostToolUpdate(parsed);
276
- return undefined;
286
+ return true;
277
287
  }
278
288
 
279
289
  if (isRpcHostUriResult(parsed)) {
280
290
  deps.onHostUriResult(parsed);
281
- return undefined;
291
+ return true;
282
292
  }
283
293
 
294
+ return false;
295
+ }
296
+
297
+ /**
298
+ * Dispatch a single parsed frame from the RPC input stream.
299
+ *
300
+ * Bash commands are dispatched in the background so the caller can keep reading
301
+ * subsequent frames while a shell command is still running. This lets a client
302
+ * send `abort_bash` while a long-running `bash` is in flight. Response
303
+ * correlation is preserved via each command's `id`; ordering across concurrent
304
+ * commands is not guaranteed and clients MUST match on `id`.
305
+ *
306
+ * @returns `undefined` when the frame was routed to a side-channel handler
307
+ * (extension UI response, host tool/URI frames) or dispatched in the
308
+ * background (`bash`). Otherwise a promise that resolves once the response
309
+ * for the command has been emitted via `output`. Errors from `handleCommand`
310
+ * on non-`bash` commands propagate; the caller is expected to wrap them.
311
+ */
312
+ export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
313
+ if (dispatchRpcControlFrame(parsed, deps)) return undefined;
314
+
284
315
  // Regular RPC command. The transport contract states each remaining frame
285
316
  // is an {@link RpcCommand}; `handleCommand`'s `default` arm surfaces
286
317
  // unknown discriminants as an error response, so we do not shape-check
@@ -309,6 +340,59 @@ export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps):
309
340
  })();
310
341
  }
311
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
+ if (dispatchRpcControlFrame(parsed, this.#deps)) return;
358
+
359
+ const command = parsed as RpcCommand;
360
+ if (command.type === "bash") {
361
+ dispatchRpcInputFrame(command, this.#deps);
362
+ return;
363
+ }
364
+
365
+ const task = this.#tail.then(
366
+ () => this.#dispatchSerialCommand(command),
367
+ () => this.#dispatchSerialCommand(command),
368
+ );
369
+ this.#tail = task.catch(() => {});
370
+ this.#tasks.add(task);
371
+ void task.finally(() => {
372
+ this.#tasks.delete(task);
373
+ });
374
+ }
375
+
376
+ /** Await every accepted serial command, including commands queued before EOF. */
377
+ async drain(): Promise<void> {
378
+ while (this.#tasks.size > 0) {
379
+ await Promise.allSettled(Array.from(this.#tasks));
380
+ }
381
+ }
382
+
383
+ async #dispatchSerialCommand(command: RpcCommand): Promise<void> {
384
+ try {
385
+ const awaited = dispatchRpcInputFrame(command, this.#deps);
386
+ if (awaited) await awaited;
387
+ } catch (err: unknown) {
388
+ const message = err instanceof Error ? err.message : String(err);
389
+ this.#deps.output(this.#deps.errorResponse(command.id, command.type, message));
390
+ } finally {
391
+ await this.#afterSerialCommand?.();
392
+ }
393
+ }
394
+ }
395
+
312
396
  /**
313
397
  * Coordinates deferred shutdown with in-flight background input tasks.
314
398
  *
@@ -547,7 +631,7 @@ export async function runRpcMode(
547
631
 
548
632
  const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
549
633
 
550
- const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
634
+ const pendingExtensionRequests = new RpcPendingExtensionRequests();
551
635
  const hostToolBridge = new RpcHostToolBridge(output);
552
636
  const hostUriBridge = new RpcHostUriBridge(output);
553
637
  const subagentRegistry = eventBus ? new RpcSubagentRegistry(eventBus, output) : undefined;
@@ -842,7 +926,7 @@ export async function runRpcMode(
842
926
  // =================================================================
843
927
 
844
928
  case "prompt": {
845
- const skillResult = await tryRunRpcSkillCommand(session, command.message);
929
+ const skillResult = await tryRunRpcSkillCommand(session, command.message, command.streamingBehavior);
846
930
  if (skillResult) {
847
931
  return success(id, "prompt", skillResult);
848
932
  }
@@ -1276,35 +1360,25 @@ export async function runRpcMode(
1276
1360
  onHostUriResult: frame => hostUriBridge.handleResult(frame),
1277
1361
  };
1278
1362
 
1279
- // Listen for JSON input using Bun's stdin. Frame dispatch lives in
1280
- // dispatchRpcInputFrame so it can be exercised directly by tests; see the
1281
- // helper's docstring for the concurrency contract.
1363
+ const inputDispatcher = new RpcInputDispatcher({
1364
+ deps: dispatchFrameDeps,
1365
+ afterSerialCommand: () => shutdownCoordinator.checkShutdownRequested(),
1366
+ });
1367
+
1368
+ // Keep the stdin reader moving: side-channel frames dispatch immediately,
1369
+ // ordinary commands serialize through inputDispatcher, and bash remains
1370
+ // background-dispatched so abort_bash can overtake it.
1282
1371
  for await (const parsed of readJsonl(Bun.stdin.stream())) {
1283
- try {
1284
- const awaited = dispatchRpcInputFrame(parsed, dispatchFrameDeps);
1285
- if (awaited) {
1286
- await awaited;
1287
- // Check for deferred shutdown request (idle between commands).
1288
- // Background-dispatched bash frames skip this check so a later
1289
- // abort_bash can still be read; the coordinator re-checks when
1290
- // each tracked task settles, so a shutdown requested mid-bash
1291
- // fires once the response frame is written even if no further
1292
- // client frames arrive.
1293
- await shutdownCoordinator.checkShutdownRequested();
1294
- }
1295
- } catch (e: unknown) {
1296
- const message = e instanceof Error ? e.message : String(e);
1297
- output(error(undefined, "parse", `Failed to parse command: ${message}`));
1298
- }
1372
+ inputDispatcher.dispatch(parsed);
1299
1373
  }
1300
1374
 
1301
- // Background bash tasks may still owe response frames; drain them before
1302
- // tearing down (stdin EOF ends the frame stream, not in-flight work).
1303
- await shutdownCoordinator.drain();
1304
-
1305
- // stdin closed — RPC client is gone, exit cleanly
1306
- hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
1375
+ // stdin closed — RPC client is gone. Fail pending side-channel requests
1376
+ // first so active/queued commands can settle, then drain accepted work.
1377
+ pendingExtensionRequests.rejectAll("RPC client disconnected before extension UI response completed");
1378
+ hostToolBridge.close("RPC client disconnected before host tool execution completed");
1307
1379
  hostUriBridge.clear("RPC client disconnected before host URI request completed");
1380
+ await inputDispatcher.drain();
1381
+ await shutdownCoordinator.drain();
1308
1382
  subagentRegistry?.dispose();
1309
1383
  process.exit(0);
1310
1384
  }
@@ -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
  });
@@ -130,6 +130,50 @@ export function assistantHasVisibleContent(message: AssistantAgentMessage): bool
130
130
  );
131
131
  }
132
132
 
133
+ /**
134
+ * Split mixed assistant turns into visible text before tool execution and
135
+ * visible text segments that must render immediately after the preceding tool.
136
+ */
137
+ export function splitAssistantMessageToolTimeline(message: AssistantAgentMessage): {
138
+ beforeTools: AssistantAgentMessage;
139
+ afterToolCalls: ReadonlyMap<string, AssistantAgentMessage>;
140
+ hasToolCalls: boolean;
141
+ } {
142
+ const beforeTools: AssistantAgentMessage["content"] = [];
143
+ const afterToolCalls = new Map<string, AssistantAgentMessage>();
144
+ let pendingAfterTool: AssistantAgentMessage["content"] = [];
145
+ let lastToolCallId: string | undefined;
146
+ let sawToolCall = false;
147
+
148
+ const displaySegment = (content: AssistantAgentMessage["content"]): AssistantAgentMessage => ({
149
+ ...message,
150
+ content,
151
+ stopReason: "stop",
152
+ errorMessage: undefined,
153
+ });
154
+
155
+ const flushPendingAfterTool = () => {
156
+ if (!lastToolCallId || pendingAfterTool.length === 0) return;
157
+ afterToolCalls.set(lastToolCallId, displaySegment(pendingAfterTool));
158
+ pendingAfterTool = [];
159
+ };
160
+
161
+ for (const content of message.content) {
162
+ if (content.type === "toolCall") {
163
+ flushPendingAfterTool();
164
+ sawToolCall = true;
165
+ lastToolCallId = content.id;
166
+ continue;
167
+ }
168
+ if (sawToolCall) pendingAfterTool.push(content);
169
+ else beforeTools.push(content);
170
+ }
171
+ flushPendingAfterTool();
172
+
173
+ if (!sawToolCall) return { beforeTools: message, afterToolCalls, hasToolCalls: false };
174
+ return { beforeTools: displaySegment(beforeTools), afterToolCalls, hasToolCalls: true };
175
+ }
176
+
133
177
  /**
134
178
  * Normalize raw tool-call arguments to a plain record, collapsing non-object or
135
179
  * array values to an empty object.
@@ -56,6 +56,7 @@ import {
56
56
  buildIrcMessageCard,
57
57
  normalizeToolArgs,
58
58
  resolveAssistantErrorMessage,
59
+ splitAssistantMessageToolTimeline,
59
60
  } from "./transcript-render-helpers";
60
61
 
61
62
  type TextBlock = { type: "text"; text: string };
@@ -350,7 +351,8 @@ export class UiHelpers {
350
351
  if (message.role !== "toolResult") flushPendingUsage();
351
352
  // Assistant messages need special handling for tool calls
352
353
  if (message.role === "assistant") {
353
- this.ctx.addMessageToChat(message);
354
+ const timeline = splitAssistantMessageToolTimeline(message);
355
+ this.ctx.addMessageToChat(timeline.beforeTools);
354
356
  const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1];
355
357
  const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
356
358
  if (assistantComponent) {
@@ -384,6 +386,7 @@ export class UiHelpers {
384
386
  continue;
385
387
  }
386
388
  resolveWaitingPoll(content.name);
389
+ const afterToolSegment = timeline.afterToolCalls.get(content.id);
387
390
 
388
391
  if (
389
392
  content.name === "read" &&
@@ -404,6 +407,16 @@ export class UiHelpers {
404
407
  false,
405
408
  content.id,
406
409
  );
410
+ } else if (afterToolSegment) {
411
+ if (!readGroup) {
412
+ readGroup = new ReadToolGroupComponent({
413
+ showContentPreview: this.ctx.settings.get("read.toolResultPreview"),
414
+ });
415
+ readGroup.setExpanded(this.ctx.toolOutputExpanded);
416
+ this.ctx.chatContainer.addChild(readGroup);
417
+ }
418
+ readGroup.updateArgs(content.arguments, content.id);
419
+ this.ctx.pendingTools.set(content.id, readGroup);
407
420
  } else {
408
421
  const normalizedArgs = normalizeToolArgs(content.arguments);
409
422
  readToolCallArgs.set(content.id, normalizedArgs);
@@ -411,6 +424,7 @@ export class UiHelpers {
411
424
  readToolCallAssistantComponents.set(content.id, assistantComponent);
412
425
  }
413
426
  }
427
+ if (afterToolSegment) this.ctx.addMessageToChat(afterToolSegment);
414
428
  continue;
415
429
  }
416
430
 
@@ -458,6 +472,7 @@ export class UiHelpers {
458
472
  } else {
459
473
  this.ctx.pendingTools.set(content.id, component);
460
474
  }
475
+ if (afterToolSegment) this.ctx.addMessageToChat(afterToolSegment);
461
476
  }
462
477
  pendingUsage = this.ctx.settings.get("display.showTokenUsage") ? message.usage : undefined;
463
478
  pendingUsageDuration = message.duration;