jeopi-cli 16.4.3 → 16.4.5

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 (34) hide show
  1. package/dist/cli.js +2817 -2814
  2. package/dist/types/advisor/advise-tool.d.ts +5 -1
  3. package/dist/types/advisor/runtime.d.ts +15 -0
  4. package/dist/types/config/model-registry.d.ts +3 -3
  5. package/dist/types/config/models-config.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +0 -10
  7. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  8. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  9. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  10. package/dist/types/session/agent-session.d.ts +1 -0
  11. package/package.json +12 -12
  12. package/src/advisor/advise-tool.ts +6 -1
  13. package/src/advisor/runtime.ts +127 -0
  14. package/src/autolearn/controller.ts +1 -1
  15. package/src/config/config-file.ts +30 -12
  16. package/src/config/model-registry.ts +7 -7
  17. package/src/config/models-config.ts +1 -1
  18. package/src/config/settings-schema.ts +0 -12
  19. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  20. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  21. package/src/internal-urls/docs-index.generated.txt +1 -1
  22. package/src/modes/components/chat-transcript-builder.ts +31 -11
  23. package/src/modes/controllers/event-controller.ts +41 -12
  24. package/src/modes/rpc/host-tools.ts +13 -0
  25. package/src/modes/rpc/rpc-mode.ts +130 -56
  26. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  27. package/src/modes/utils/ui-helpers.ts +16 -1
  28. package/src/session/agent-session.ts +95 -11
  29. package/src/task/render.ts +51 -44
  30. package/src/tools/bash.ts +0 -11
  31. package/src/tools/path-utils.ts +6 -1
  32. package/src/utils/title-generator.ts +5 -1
  33. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  34. package/src/tools/bash-command-fixup.ts +0 -37
@@ -34,6 +34,7 @@ import {
34
34
  buildIrcMessageCard,
35
35
  normalizeToolArgs,
36
36
  resolveAssistantErrorMessage,
37
+ splitAssistantMessageToolTimeline,
37
38
  } from "../utils/transcript-render-helpers";
38
39
  import { createAdvisorMessageCard } from "./advisor-message";
39
40
  import { AssistantMessageComponent } from "./assistant-message";
@@ -268,13 +269,16 @@ export class ChatTranscriptBuilder {
268
269
  }
269
270
 
270
271
  #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
272
+ const hideThinkingBlock = this.deps.hideThinkingBlock?.() ?? false;
273
+ const proseOnlyThinking = this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true;
274
+ const timeline = splitAssistantMessageToolTimeline(message);
271
275
  const assistantComponent = new AssistantMessageComponent(
272
- message,
273
- this.deps.hideThinkingBlock?.() ?? false,
276
+ timeline.beforeTools,
277
+ hideThinkingBlock,
274
278
  () => this.deps.requestRender(),
275
- this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
276
- undefined, // placeholder for imageBudget
277
- this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true,
279
+ this.deps.getMessageRenderer ? undefined : [],
280
+ undefined,
281
+ proseOnlyThinking,
278
282
  );
279
283
  this.container.addChild(assistantComponent);
280
284
 
@@ -286,18 +290,30 @@ export class ChatTranscriptBuilder {
286
290
  this.#lastAssistantUsage = message.usage;
287
291
  }
288
292
 
289
- const hasVisibleAssistantContent = assistantHasVisibleContent(message);
290
- if (hasVisibleAssistantContent) {
291
- // New visible turn content closes the current read run (mirrors rebuild).
293
+ if (assistantHasVisibleContent(message)) {
292
294
  this.#readGroup?.seal();
293
295
  this.#readGroup = null;
294
296
  }
295
297
 
296
298
  const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(message);
299
+ const appendAssistantSegment = (segment: Extract<AgentMessage, { role: "assistant" }> | undefined) => {
300
+ if (!segment || !assistantHasVisibleContent(segment)) return;
301
+ this.container.addChild(
302
+ new AssistantMessageComponent(
303
+ segment,
304
+ hideThinkingBlock,
305
+ () => this.deps.requestRender(),
306
+ this.deps.getMessageRenderer ? undefined : [],
307
+ undefined,
308
+ proseOnlyThinking,
309
+ ),
310
+ );
311
+ };
297
312
 
298
313
  for (const content of message.content) {
299
314
  if (content.type !== "toolCall") continue;
300
315
  this.#resolveWaitingPoll(content.name);
316
+ const afterToolSegment = timeline.afterToolCalls.get(content.id);
301
317
 
302
318
  if (
303
319
  content.name === "read" &&
@@ -312,10 +328,14 @@ export class ChatTranscriptBuilder {
312
328
  false,
313
329
  content.id,
314
330
  );
331
+ } else if (afterToolSegment) {
332
+ const group = this.#ensureReadGroup();
333
+ group.updateArgs(content.arguments, content.id);
334
+ this.#pendingTools.set(content.id, group);
315
335
  } else {
316
- const normalizedArgs = normalizeToolArgs(content.arguments);
317
- this.#readArgs.set(content.id, normalizedArgs);
336
+ this.#readArgs.set(content.id, normalizeToolArgs(content.arguments));
318
337
  }
338
+ appendAssistantSegment(afterToolSegment);
319
339
  continue;
320
340
  }
321
341
 
@@ -325,7 +345,6 @@ export class ChatTranscriptBuilder {
325
345
  content.name,
326
346
  content.arguments,
327
347
  {
328
- // Images can't be sliced through the scroll viewport; keep them off.
329
348
  showImages: false,
330
349
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
331
350
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -348,6 +367,7 @@ export class ChatTranscriptBuilder {
348
367
  } else {
349
368
  this.#pendingTools.set(content.id, component);
350
369
  }
370
+ appendAssistantSegment(afterToolSegment);
351
371
  }
352
372
 
353
373
  this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
@@ -1,4 +1,4 @@
1
- import type { ImageContent } from "jeopi-ai";
1
+ import type { AssistantMessage, ImageContent } from "jeopi-ai";
2
2
  import * as AIError from "jeopi-ai/error";
3
3
  import { getStreamingPartialJson } from "jeopi-ai/utils/block-symbols";
4
4
  import { type Component, Loader, TERMINAL } from "jeopi-tui";
@@ -31,6 +31,7 @@ import { vocalizer } from "../../tts/vocalizer";
31
31
  import { canonicalizeMessage } from "../../utils/thinking-display";
32
32
  import { interruptHint } from "../shared";
33
33
  import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
34
+ import { assistantHasVisibleContent, splitAssistantMessageToolTimeline } from "../utils/transcript-render-helpers";
34
35
  import { StreamingRevealController } from "./streaming-reveal";
35
36
  import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal";
36
37
 
@@ -76,6 +77,7 @@ export class EventController {
76
77
  #lastIntent: string | undefined = undefined;
77
78
  #backgroundTaskCallIds = new Set<string>();
78
79
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
80
+ #toolTimelineComponents = new Map<string, Component>();
79
81
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
80
82
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
81
83
  // Assistant component whose turn-ending error is currently mirrored in the
@@ -235,6 +237,24 @@ export class EventController {
235
237
  assistantComponent.setToolResultImages(toolCallId, images);
236
238
  return true;
237
239
  }
240
+
241
+ #insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): void {
242
+ const children = this.ctx.chatContainer.children;
243
+ const anchorIndex = anchor ? children.indexOf(anchor) : -1;
244
+ this.ctx.chatContainer.addChild(component);
245
+ if (anchorIndex < 0) return;
246
+ children.splice(children.length - 1, 1);
247
+ children.splice(anchorIndex + 1, 0, component);
248
+ }
249
+
250
+ #appendPostToolAssistantSegment(toolCallId: string, segment: AssistantMessage | undefined) {
251
+ if (!segment || !assistantHasVisibleContent(segment)) return undefined;
252
+ const component = createAssistantMessageComponent(this.ctx);
253
+ component.updateContent(segment);
254
+ component.markTranscriptBlockFinalized();
255
+ this.#insertAfterTranscriptComponent(this.#toolTimelineComponents.get(toolCallId), component);
256
+ return component;
257
+ }
238
258
  #updateWorkingMessageFromIntent(intent: unknown): void {
239
259
  if (this.ctx.session.isAborting) return;
240
260
  // Streamed JSON can deliver non-string `i` (object, number, boolean) before
@@ -262,6 +282,7 @@ export class EventController {
262
282
  this.#lastVisibleBlockCount = 0;
263
283
  this.#renderedCustomMessages.clear();
264
284
  this.#lastIntent = undefined;
285
+ this.#toolTimelineComponents.clear();
265
286
  this.#backgroundTaskCallIds.clear();
266
287
  this.#readToolCallArgs.clear();
267
288
  this.#readToolCallAssistantComponents.clear();
@@ -306,6 +327,7 @@ export class EventController {
306
327
  }
307
328
 
308
329
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
330
+ this.#toolTimelineComponents.clear();
309
331
  this.#lastIntent = undefined;
310
332
  this.#readToolCallArgs.clear();
311
333
  this.#readToolCallAssistantComponents.clear();
@@ -626,6 +648,7 @@ export class EventController {
626
648
  const group = this.#getReadGroup();
627
649
  group.updateArgs(content.arguments, content.id);
628
650
  this.ctx.pendingTools.set(content.id, group);
651
+ this.#toolTimelineComponents.set(content.id, group);
629
652
  }
630
653
  continue;
631
654
  }
@@ -672,6 +695,7 @@ export class EventController {
672
695
  component.setExpanded(this.ctx.toolOutputExpanded);
673
696
  this.ctx.chatContainer.addChild(component);
674
697
  this.ctx.pendingTools.set(content.id, component);
698
+ this.#toolTimelineComponents.set(content.id, component);
675
699
  this.#toolArgsReveal.bind(content.id, component);
676
700
  } else {
677
701
  const component = this.ctx.pendingTools.get(content.id);
@@ -745,15 +769,12 @@ export class EventController {
745
769
  errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt);
746
770
  this.ctx.streamingMessage.errorMessage = errorMessage;
747
771
  }
748
- if (silentlyAborted || ttsrSilenced) {
749
- // Silence the streaming render by downgrading stopReason to "stop" for
750
- // display only — does NOT mutate the persisted message's stopReason
751
- // (the marker on errorMessage drives replay-side suppression).
752
- const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
753
- this.ctx.streamingComponent.updateContent(msgWithoutAbort);
754
- } else {
755
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
756
- }
772
+ const displayMessage: AssistantMessage =
773
+ silentlyAborted || ttsrSilenced
774
+ ? { ...this.ctx.streamingMessage, stopReason: "stop" as const }
775
+ : this.ctx.streamingMessage;
776
+ const displayTimeline = splitAssistantMessageToolTimeline(displayMessage);
777
+ this.ctx.streamingComponent.updateContent(displayTimeline.beforeTools);
757
778
 
758
779
  if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
759
780
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
@@ -783,8 +804,13 @@ export class EventController {
783
804
  }
784
805
  this.ctx.lastAssistantUsage = usage;
785
806
  }
786
- this.#lastAssistantComponent = this.ctx.streamingComponent;
787
- this.#lastAssistantComponent.markTranscriptBlockFinalized();
807
+ this.ctx.streamingComponent.markTranscriptBlockFinalized();
808
+ let lastPostToolAssistantComponent: AssistantMessageComponent | undefined;
809
+ for (const [toolCallId, segment] of displayTimeline.afterToolCalls) {
810
+ const component = this.#appendPostToolAssistantSegment(toolCallId, segment);
811
+ if (component) lastPostToolAssistantComponent = component;
812
+ }
813
+ this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
788
814
  if (settings.get("display.showTokenUsage")) {
789
815
  this.ctx.chatContainer.addChild(
790
816
  createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
@@ -821,6 +847,7 @@ export class EventController {
821
847
  const group = this.#getReadGroup();
822
848
  group.updateArgs(event.args, event.toolCallId);
823
849
  this.ctx.pendingTools.set(event.toolCallId, group);
850
+ this.#toolTimelineComponents.set(event.toolCallId, group);
824
851
  }
825
852
  this.ctx.ui.requestRender();
826
853
  return;
@@ -846,6 +873,7 @@ export class EventController {
846
873
  component.setExpanded(this.ctx.toolOutputExpanded);
847
874
  this.ctx.chatContainer.addChild(component);
848
875
  this.ctx.pendingTools.set(event.toolCallId, component);
876
+ this.#toolTimelineComponents.set(event.toolCallId, component);
849
877
  this.ctx.ui.requestRender();
850
878
  } else {
851
879
  // The tool is about to run, so its arguments are final and validated.
@@ -918,6 +946,7 @@ export class EventController {
918
946
  }
919
947
  component = group;
920
948
  this.ctx.pendingTools.set(event.toolCallId, group);
949
+ this.#toolTimelineComponents.set(event.toolCallId, group);
921
950
  }
922
951
  component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
923
952
  this.ctx.pendingTools.delete(event.toolCallId);
@@ -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
  }
@@ -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;