@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  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/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -116,16 +116,18 @@ function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; ph
116
116
  // Build system reminder
117
117
  // =============================================================================
118
118
 
119
- function buildSystemReminder(action: string, phases: TodoPhase[]): string {
119
+ function buildSystemReminder(action: string, phases: TodoPhase[], removed = false): string {
120
120
  const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd();
121
- return [
122
- "<system-reminder>",
123
- `The user manually modified the todo list (${action}).`,
124
- "Current todo list:",
125
- "",
126
- md,
127
- "</system-reminder>",
128
- ].join("\n");
121
+ const lines = ["<system-reminder>", `The user manually modified the todo list (${action}).`];
122
+ if (removed) {
123
+ lines.push(
124
+ phases.length === 0
125
+ ? "The user intentionally cleared the todo list. Do NOT recreate or re-populate it unless the user explicitly asks; continue the current request without a todo list."
126
+ : "The user intentionally removed the entries no longer shown below. Do NOT re-add them unless the user explicitly asks.",
127
+ );
128
+ }
129
+ lines.push("Current todo list:", "", md, "</system-reminder>");
130
+ return lines.join("\n");
129
131
  }
130
132
 
131
133
  export class TodoCommandController {
@@ -366,7 +368,7 @@ export class TodoCommandController {
366
368
  const current = this.#currentPhases();
367
369
  const trimmed = rest.trim();
368
370
  if (!trimmed) {
369
- this.#commit([], "/todo rm (all)");
371
+ this.#commit([], "/todo rm (all)", { removed: true });
370
372
  this.ctx.showStatus("Cleared all todos.");
371
373
  return;
372
374
  }
@@ -377,7 +379,7 @@ export class TodoCommandController {
377
379
  this.ctx.showError(errors.join("; "));
378
380
  return;
379
381
  }
380
- this.#commit(phases, `/todo rm ${taskHit.task.content}`);
382
+ this.#commit(phases, `/todo rm ${taskHit.task.content}`, { removed: true });
381
383
  this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
382
384
  return;
383
385
  }
@@ -388,7 +390,7 @@ export class TodoCommandController {
388
390
  this.ctx.showError(errors.join("; "));
389
391
  return;
390
392
  }
391
- this.#commit(phases, `/todo rm ${phaseHit.name}`);
393
+ this.#commit(phases, `/todo rm ${phaseHit.name}`, { removed: true });
392
394
  this.ctx.showStatus(`Removed phase: ${phaseHit.name}`);
393
395
  return;
394
396
  }
@@ -454,7 +456,7 @@ export class TodoCommandController {
454
456
  }
455
457
  }
456
458
 
457
- #commit(nextPhases: TodoPhase[], action: string): void {
459
+ #commit(nextPhases: TodoPhase[], action: string, opts?: { removed?: boolean }): void {
458
460
  // 1. In-memory + UI state
459
461
  this.ctx.session.setTodoPhases(nextPhases);
460
462
  this.ctx.setTodos(nextPhases);
@@ -463,7 +465,9 @@ export class TodoCommandController {
463
465
  this.ctx.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: nextPhases });
464
466
 
465
467
  // 3. Inject system reminder so the agent learns about the change next turn.
466
- const reminderText = buildSystemReminder(action, nextPhases);
468
+ // Removals carry explicit intent so the agent does not rebuild the
469
+ // cleared/removed items on its next turn (issue #5258).
470
+ const reminderText = buildSystemReminder(action, nextPhases, opts?.removed ?? false);
467
471
  const message = {
468
472
  role: "developer" as const,
469
473
  content: [{ type: "text" as const, text: reminderText }],
@@ -71,7 +71,7 @@ import type {
71
71
  import type { CompactOptions } from "../extensibility/extensions/types";
72
72
  import type { Skill } from "../extensibility/skills";
73
73
  import { loadSlashCommands } from "../extensibility/slash-commands";
74
- import { type GuidedGoalMessage, runGuidedGoalTurn } from "../goals/guided-setup";
74
+ import { type GuidedGoalMessage, newGuidedGoalSessionId, runGuidedGoalTurn } from "../goals/guided-setup";
75
75
  import type { Goal, GoalModeState } from "../goals/state";
76
76
  import { resolveLocalUrlToPath } from "../internal-urls";
77
77
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
@@ -3115,8 +3115,12 @@ export class InteractiveMode implements InteractiveModeContext {
3115
3115
 
3116
3116
  const messages: GuidedGoalMessage[] = [{ role: "user", content: initial }];
3117
3117
  let latestDraftObjective: string | undefined;
3118
+ // One Codex side session for the whole interview: every follow-up turn
3119
+ // reuses it so a multi-question interview shares a single websocket-only
3120
+ // Codex socket instead of leaking one per turn (#5471 review).
3121
+ const guidedGoalSessionId = newGuidedGoalSessionId(this.session);
3118
3122
  for (let turn = 0; turn < 6; turn++) {
3119
- const result = await runGuidedGoalTurn(this.session, { messages });
3123
+ const result = await runGuidedGoalTurn(this.session, { messages, sideSessionId: guidedGoalSessionId });
3120
3124
  if (result.objective?.trim()) latestDraftObjective = result.objective.trim();
3121
3125
  if (result.kind === "question") {
3122
3126
  messages.push({ role: "assistant", content: result.question });
@@ -4156,7 +4160,7 @@ export class InteractiveMode implements InteractiveModeContext {
4156
4160
  await this.#selectorController.showDebugSelector();
4157
4161
  }
4158
4162
 
4159
- showAgentHub(options?: { requireContent?: boolean }): void {
4163
+ showAgentHub(options?: { requireContent?: boolean; armCloseTap?: boolean }): void {
4160
4164
  this.#selectorController.showAgentHub(this.#observerRegistry, options);
4161
4165
  }
4162
4166
 
@@ -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
 
@@ -156,7 +156,12 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
156
156
  const commandName = commandText.slice(1, spaceIndex);
157
157
  const command = this.#commands.find(cmd => cmd.name === commandName || cmd.aliases?.includes(commandName));
158
158
  if (command && (!("allowArgs" in command) || command.allowArgs !== false)) {
159
- return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
159
+ const argumentSuggestions = await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
160
+ if (argumentSuggestions) return argumentSuggestions;
161
+ // No slash-argument completion for this input: fall through to
162
+ // internal-url completion only. `#` prompt-action tokens stay
163
+ // literal text inside slash command arguments.
164
+ return getInternalUrlSuggestions(textBeforeCursor, this.#basePath);
160
165
  }
161
166
  }
162
167
 
@@ -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
  }
@@ -368,7 +368,7 @@ export interface InteractiveModeContext {
368
368
  showProviderSetup(): Promise<void>;
369
369
  showHookConfirm(title: string, message: string): Promise<boolean>;
370
370
  showDebugSelector(): Promise<void>;
371
- showAgentHub(options?: { requireContent?: boolean }): void;
371
+ showAgentHub(options?: { requireContent?: boolean; armCloseTap?: boolean }): void;
372
372
  resetObserverRegistry(): void;
373
373
 
374
374
  // Input handling
@@ -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
  });
@@ -136,6 +136,60 @@ export function assistantHasVisibleContent(message: AssistantAgentMessage): bool
136
136
  );
137
137
  }
138
138
 
139
+ /**
140
+ * Split mixed assistant turns into visible text before tool execution and
141
+ * visible text segments that must render immediately after the preceding tool.
142
+ * Cursor can return intro text, tool calls, progress text, and the final answer
143
+ * in one assistant message; keeping every text block in the leading assistant
144
+ * block buries post-tool text above tool results in the transcript.
145
+ */
146
+ export function splitAssistantMessageToolTimeline(message: AssistantAgentMessage): {
147
+ beforeTools: AssistantAgentMessage;
148
+ afterToolCalls: ReadonlyMap<string, AssistantAgentMessage>;
149
+ hasToolCalls: boolean;
150
+ } {
151
+ const beforeTools: AssistantAgentMessage["content"] = [];
152
+ const afterToolCalls = new Map<string, AssistantAgentMessage>();
153
+ let pendingAfterTool: AssistantAgentMessage["content"] = [];
154
+ let lastToolCallId: string | undefined;
155
+ let sawToolCall = false;
156
+
157
+ const displaySegment = (content: AssistantAgentMessage["content"]): AssistantAgentMessage => ({
158
+ ...message,
159
+ content,
160
+ stopReason: "stop",
161
+ errorMessage: undefined,
162
+ retryRecovery: undefined,
163
+ });
164
+
165
+ const flushPendingAfterTool = () => {
166
+ if (!lastToolCallId || pendingAfterTool.length === 0) return;
167
+ afterToolCalls.set(lastToolCallId, displaySegment(pendingAfterTool));
168
+ pendingAfterTool = [];
169
+ };
170
+
171
+ for (const content of message.content) {
172
+ if (content.type === "toolCall") {
173
+ flushPendingAfterTool();
174
+ sawToolCall = true;
175
+ lastToolCallId = content.id;
176
+ continue;
177
+ }
178
+ if (sawToolCall) {
179
+ pendingAfterTool.push(content);
180
+ } else {
181
+ beforeTools.push(content);
182
+ }
183
+ }
184
+ flushPendingAfterTool();
185
+
186
+ if (!sawToolCall) {
187
+ return { beforeTools: message, afterToolCalls, hasToolCalls: false };
188
+ }
189
+
190
+ return { beforeTools: displaySegment(beforeTools), afterToolCalls, hasToolCalls: true };
191
+ }
192
+
139
193
  /**
140
194
  * Normalize raw tool-call arguments to a plain record, collapsing non-object or
141
195
  * array values to an empty object.
@@ -57,6 +57,7 @@ import {
57
57
  buildIrcMessageCard,
58
58
  normalizeToolArgs,
59
59
  resolveAssistantErrorPresentation,
60
+ splitAssistantMessageToolTimeline,
60
61
  } from "./transcript-render-helpers";
61
62
 
62
63
  type TextBlock = { type: "text"; text: string };
@@ -251,7 +252,10 @@ export class UiHelpers {
251
252
  break;
252
253
  }
253
254
  case "assistant": {
254
- const assistantComponent = createAssistantMessageComponent(this.ctx, message);
255
+ const assistantComponent = createAssistantMessageComponent(
256
+ this.ctx,
257
+ splitAssistantMessageToolTimeline(message).beforeTools,
258
+ );
255
259
  this.ctx.chatContainer.addChild(assistantComponent);
256
260
  break;
257
261
  }
@@ -357,6 +361,7 @@ export class UiHelpers {
357
361
  if (message.role !== "toolResult") flushPendingUsage();
358
362
  // Assistant messages need special handling for tool calls
359
363
  if (message.role === "assistant") {
364
+ const timeline = splitAssistantMessageToolTimeline(message);
360
365
  this.ctx.addMessageToChat(message);
361
366
  const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1];
362
367
  const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
@@ -383,6 +388,11 @@ export class UiHelpers {
383
388
  const errorPresentation = resolveAssistantErrorPresentation(message, this.ctx.viewSession.retryAttempt);
384
389
  const hasErrorStop = errorPresentation.kind === "full";
385
390
  const errorMessage = hasErrorStop ? errorPresentation.text : null;
391
+ const appendAssistantSegment = (segment: AssistantMessage | undefined) => {
392
+ if (!segment || !assistantHasVisibleContent(segment)) return;
393
+ const component = createAssistantMessageComponent(this.ctx, segment);
394
+ this.ctx.chatContainer.addChild(component);
395
+ };
386
396
 
387
397
  // Render tool call components
388
398
  for (const content of message.content) {
@@ -390,6 +400,7 @@ export class UiHelpers {
390
400
  continue;
391
401
  }
392
402
  resolveWaitingPoll(content.name);
403
+ const afterToolSegment = timeline.afterToolCalls.get(content.id);
393
404
 
394
405
  if (
395
406
  content.name === "read" &&
@@ -410,6 +421,19 @@ export class UiHelpers {
410
421
  false,
411
422
  content.id,
412
423
  );
424
+ } else if (afterToolSegment) {
425
+ if (!readGroup) {
426
+ readGroup = new ReadToolGroupComponent({
427
+ showContentPreview: this.ctx.settings.get("read.toolResultPreview"),
428
+ });
429
+ readGroup.setExpanded(this.ctx.toolOutputExpanded);
430
+ this.ctx.chatContainer.addChild(readGroup);
431
+ }
432
+ readGroup.updateArgs(content.arguments, content.id);
433
+ this.ctx.pendingTools.set(content.id, readGroup);
434
+ if (assistantComponent) {
435
+ readToolCallAssistantComponents.set(content.id, assistantComponent);
436
+ }
413
437
  } else {
414
438
  const normalizedArgs = normalizeToolArgs(content.arguments);
415
439
  readToolCallArgs.set(content.id, normalizedArgs);
@@ -417,6 +441,7 @@ export class UiHelpers {
417
441
  readToolCallAssistantComponents.set(content.id, assistantComponent);
418
442
  }
419
443
  }
444
+ appendAssistantSegment(afterToolSegment);
420
445
  continue;
421
446
  }
422
447
 
@@ -464,6 +489,7 @@ export class UiHelpers {
464
489
  } else {
465
490
  this.ctx.pendingTools.set(content.id, component);
466
491
  }
492
+ appendAssistantSegment(afterToolSegment);
467
493
  }
468
494
  // Dangling toolCalls (no result on the resolved path — failed or
469
495
  // retried turns, results on sibling branches) were stripped by the