@oh-my-pi/pi-coding-agent 15.1.2 → 15.1.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 (155) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/types/async/job-manager.d.ts +3 -2
  3. package/dist/types/cli/auth-broker-cli.d.ts +25 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +18 -0
  5. package/dist/types/cli/grievances-cli.d.ts +12 -0
  6. package/dist/types/commands/auth-broker.d.ts +54 -0
  7. package/dist/types/commands/auth-gateway.d.ts +32 -0
  8. package/dist/types/commands/grievances.d.ts +1 -1
  9. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -1
  10. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -1
  11. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -1
  12. package/dist/types/config/model-registry.d.ts +3 -0
  13. package/dist/types/config/models-config-schema.d.ts +1 -0
  14. package/dist/types/config/settings-schema.d.ts +46 -0
  15. package/dist/types/discovery/agents.d.ts +12 -1
  16. package/dist/types/edit/renderer.d.ts +3 -0
  17. package/dist/types/eval/index.d.ts +0 -2
  18. package/dist/types/goals/tools/goal-tool.d.ts +10 -2
  19. package/dist/types/index.d.ts +0 -1
  20. package/dist/types/internal-urls/index.d.ts +1 -1
  21. package/dist/types/internal-urls/{pi-protocol.d.ts → omp-protocol.d.ts} +3 -3
  22. package/dist/types/internal-urls/types.d.ts +1 -1
  23. package/dist/types/main.d.ts +11 -2
  24. package/dist/types/modes/acp/acp-agent.d.ts +2 -1
  25. package/dist/types/modes/acp/acp-event-mapper.d.ts +13 -1
  26. package/dist/types/modes/acp/acp-mode.d.ts +3 -1
  27. package/dist/types/modes/emoji-autocomplete.d.ts +16 -0
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  30. package/dist/types/plan-mode/approved-plan.d.ts +10 -4
  31. package/dist/types/sdk.d.ts +10 -3
  32. package/dist/types/session/agent-session.d.ts +7 -3
  33. package/dist/types/session/auth-broker-config.d.ts +13 -0
  34. package/dist/types/session/auth-storage.d.ts +1 -1
  35. package/dist/types/session/client-bridge.d.ts +3 -0
  36. package/dist/types/tools/eval.d.ts +41 -7
  37. package/dist/types/tools/irc.d.ts +8 -2
  38. package/dist/types/tools/report-tool-issue.d.ts +118 -1
  39. package/dist/types/tools/resolve.d.ts +8 -2
  40. package/examples/custom-tools/README.md +3 -12
  41. package/examples/extensions/README.md +2 -15
  42. package/examples/extensions/api-demo.ts +1 -7
  43. package/package.json +7 -7
  44. package/src/async/job-manager.ts +111 -13
  45. package/src/autoresearch/tools/init-experiment.ts +11 -33
  46. package/src/autoresearch/tools/log-experiment.ts +10 -24
  47. package/src/autoresearch/tools/run-experiment.ts +1 -1
  48. package/src/autoresearch/tools/update-notes.ts +2 -9
  49. package/src/cli/auth-broker-cli.ts +746 -0
  50. package/src/cli/auth-gateway-cli.ts +342 -0
  51. package/src/cli/grievances-cli.ts +109 -16
  52. package/src/cli/update-cli.ts +1 -5
  53. package/src/cli.ts +4 -2
  54. package/src/commands/auth-broker.ts +96 -0
  55. package/src/commands/auth-gateway.ts +61 -0
  56. package/src/commands/grievances.ts +13 -8
  57. package/src/commands/launch.ts +1 -1
  58. package/src/commit/agentic/agent.ts +2 -0
  59. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  60. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  61. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  62. package/src/commit/agentic/tools/git-overview.ts +2 -2
  63. package/src/commit/agentic/tools/propose-changelog.ts +1 -3
  64. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  65. package/src/commit/agentic/tools/schemas.ts +1 -9
  66. package/src/config/model-equivalence.ts +279 -174
  67. package/src/config/model-registry.ts +37 -6
  68. package/src/config/model-resolver.ts +13 -8
  69. package/src/config/models-config-schema.ts +8 -0
  70. package/src/config/settings-schema.ts +52 -0
  71. package/src/cursor.ts +1 -1
  72. package/src/debug/log-formatting.ts +1 -1
  73. package/src/debug/log-viewer.ts +1 -1
  74. package/src/debug/profiler.ts +4 -0
  75. package/src/debug/raw-sse-buffer.ts +100 -59
  76. package/src/debug/raw-sse.ts +1 -1
  77. package/src/discovery/agents.ts +15 -4
  78. package/src/edit/modes/apply-patch.ts +1 -5
  79. package/src/edit/modes/patch.ts +5 -5
  80. package/src/edit/modes/replace.ts +5 -5
  81. package/src/edit/renderer.ts +2 -1
  82. package/src/edit/streaming.ts +1 -1
  83. package/src/eval/index.ts +0 -2
  84. package/src/eval/js/shared/runtime.ts +107 -2
  85. package/src/eval/py/kernel.ts +1 -1
  86. package/src/exa/researcher.ts +4 -4
  87. package/src/exa/search.ts +10 -22
  88. package/src/exa/websets.ts +33 -33
  89. package/src/extensibility/typebox.ts +44 -17
  90. package/src/goals/tools/goal-tool.ts +3 -3
  91. package/src/index.ts +0 -3
  92. package/src/internal-urls/docs-index.generated.ts +21 -18
  93. package/src/internal-urls/index.ts +1 -1
  94. package/src/internal-urls/{pi-protocol.ts → omp-protocol.ts} +10 -10
  95. package/src/internal-urls/router.ts +3 -3
  96. package/src/internal-urls/types.ts +1 -1
  97. package/src/lsp/types.ts +8 -11
  98. package/src/main.ts +216 -146
  99. package/src/mcp/tool-bridge.ts +3 -3
  100. package/src/modes/acp/acp-agent.ts +203 -57
  101. package/src/modes/acp/acp-client-bridge.ts +2 -1
  102. package/src/modes/acp/acp-event-mapper.ts +208 -32
  103. package/src/modes/acp/acp-mode.ts +11 -3
  104. package/src/modes/components/bash-execution.ts +1 -1
  105. package/src/modes/components/diff.ts +1 -2
  106. package/src/modes/components/eval-execution.ts +1 -1
  107. package/src/modes/components/oauth-selector.ts +38 -2
  108. package/src/modes/components/tool-execution.ts +1 -2
  109. package/src/modes/components/tree-selector.ts +26 -7
  110. package/src/modes/controllers/command-controller.ts +95 -34
  111. package/src/modes/controllers/input-controller.ts +4 -3
  112. package/src/modes/data/emojis.json +1 -0
  113. package/src/modes/emoji-autocomplete.ts +285 -0
  114. package/src/modes/interactive-mode.ts +92 -19
  115. package/src/modes/print-mode.ts +3 -3
  116. package/src/modes/prompt-action-autocomplete.ts +14 -0
  117. package/src/plan-mode/approved-plan.ts +30 -9
  118. package/src/prompts/system/system-prompt.md +1 -1
  119. package/src/prompts/system/ttsr-tool-reminder.md +5 -0
  120. package/src/prompts/tools/ask.md +4 -3
  121. package/src/prompts/tools/eval.md +25 -26
  122. package/src/prompts/tools/read.md +1 -1
  123. package/src/prompts/tools/resolve.md +1 -1
  124. package/src/prompts/tools/search.md +1 -1
  125. package/src/prompts/tools/web-search.md +1 -1
  126. package/src/sdk.ts +81 -8
  127. package/src/session/agent-session.ts +362 -131
  128. package/src/session/agent-storage.ts +7 -2
  129. package/src/session/auth-broker-config.ts +102 -0
  130. package/src/session/auth-storage.ts +7 -1
  131. package/src/session/client-bridge.ts +3 -0
  132. package/src/session/streaming-output.ts +1 -1
  133. package/src/task/types.ts +10 -35
  134. package/src/tools/bash-interactive.ts +4 -1
  135. package/src/tools/bash-pty-selection.ts +2 -2
  136. package/src/tools/browser.ts +12 -20
  137. package/src/tools/eval.ts +77 -100
  138. package/src/tools/gh.ts +21 -45
  139. package/src/tools/hindsight-recall.ts +1 -1
  140. package/src/tools/hindsight-reflect.ts +2 -2
  141. package/src/tools/hindsight-retain.ts +3 -7
  142. package/src/tools/index.ts +8 -1
  143. package/src/tools/inspect-image.ts +4 -1
  144. package/src/tools/irc.ts +4 -12
  145. package/src/tools/job.ts +3 -11
  146. package/src/tools/report-tool-issue.ts +462 -17
  147. package/src/tools/resolve.ts +2 -7
  148. package/src/tools/todo-write.ts +8 -15
  149. package/src/utils/title-generator.ts +3 -0
  150. package/src/web/search/index.ts +6 -6
  151. package/dist/types/eval/parse.d.ts +0 -28
  152. package/dist/types/eval/sniff.d.ts +0 -11
  153. package/src/eval/eval.lark +0 -36
  154. package/src/eval/parse.ts +0 -407
  155. package/src/eval/sniff.ts +0 -28
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import type { TSchema } from "@oh-my-pi/pi-ai";
8
- import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
8
+ import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
9
9
  import { untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import type { SourceMeta } from "../capability/types";
11
11
  import type {
@@ -231,7 +231,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
231
231
  this.name = createMCPToolName(connection.name, tool.name);
232
232
  this.label = `${connection.name}/${tool.name}`;
233
233
  this.description = tool.description ?? `MCP tool from ${connection.name}`;
234
- this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
234
+ this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
235
235
  this.mcpToolName = tool.name;
236
236
  this.mcpServerName = connection.name;
237
237
  }
@@ -324,7 +324,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
324
324
  this.name = createMCPToolName(serverName, tool.name);
325
325
  this.label = `${serverName}/${tool.name}`;
326
326
  this.description = tool.description ?? `MCP tool from ${serverName}`;
327
- this.parameters = sanitizeSchemaForMCP(tool.inputSchema) as TSchema;
327
+ this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
328
328
  this.mcpToolName = tool.name;
329
329
  this.mcpServerName = serverName;
330
330
  this.#fallbackProvider = source?.provider;
@@ -66,7 +66,11 @@ import {
66
66
  import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
67
67
  import { parseThinkingLevel } from "../../thinking";
68
68
  import { createAcpClientBridge } from "./acp-client-bridge";
69
- import { mapAgentSessionEventToAcpSessionUpdates, mapToolKind } from "./acp-event-mapper";
69
+ import {
70
+ buildToolCallStartUpdate,
71
+ mapAgentSessionEventToAcpSessionUpdates,
72
+ normalizeReplayToolArguments,
73
+ } from "./acp-event-mapper";
70
74
  import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth";
71
75
 
72
76
  const ACP_DEFAULT_MODE_ID = "default";
@@ -86,6 +90,9 @@ const SESSION_PAGE_SIZE = 50;
86
90
  * wait past this guard without hard-coding the literal.
87
91
  */
88
92
  export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
93
+ const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
94
+ const ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS = 250;
95
+ const ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES = 3;
89
96
 
90
97
  type AgentImageContent = {
91
98
  type: "image";
@@ -102,6 +109,13 @@ type PromptTurnState = {
102
109
  userMessageId: string;
103
110
  cancelRequested: boolean;
104
111
  settled: boolean;
112
+ /**
113
+ * `abort()` is in-flight (or its bounded-timeout race). `undefined` while the turn is
114
+ * running normally and after cleanup completes. The turn occupies `record.promptTurn`
115
+ * for as long as either `!settled` or `cleanup` is set — that combined window is the
116
+ * "turn in flight" predicate (`isPromptTurnInFlight`) every consumer gates on.
117
+ */
118
+ cleanup: Promise<void> | undefined;
105
119
  usageBaseline: UsageStatistics;
106
120
  unsubscribe: (() => void) | undefined;
107
121
  resolve: (value: PromptResponse) => void;
@@ -109,6 +123,16 @@ type PromptTurnState = {
109
123
  promise: Promise<PromptResponse>;
110
124
  };
111
125
 
126
+ /**
127
+ * A turn is "in flight" from the moment `prompt()` reserves the slot until `settled` is
128
+ * true AND any cancel cleanup has completed. Fork/queue/event gating all depend on this
129
+ * combined window — a settled-but-still-aborting turn is not safe to fork from, queue
130
+ * onto, or forward late events for.
131
+ */
132
+ function isPromptTurnInFlight(turn: PromptTurnState | undefined): turn is PromptTurnState {
133
+ return turn !== undefined && (!turn.settled || turn.cleanup !== undefined);
134
+ }
135
+
112
136
  type ManagedSessionRecord = {
113
137
  session: AgentSession;
114
138
  mcpManager: MCPManager | undefined;
@@ -116,6 +140,7 @@ type ManagedSessionRecord = {
116
140
  promptQueue: PromptQueueState;
117
141
  liveMessageId: string | undefined;
118
142
  liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
143
+ toolArgsById: Map<string, unknown>;
119
144
  extensionsConfigured: boolean;
120
145
  // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
121
146
  // in `#disposeSessionRecord`. Lives independent of any prompt turn.
@@ -132,6 +157,14 @@ type ReplayableMessage = {
132
157
  isError?: boolean;
133
158
  };
134
159
 
160
+ type ReplayableToolItem = {
161
+ type?: unknown;
162
+ id?: unknown;
163
+ name?: unknown;
164
+ arguments?: unknown;
165
+ input?: unknown;
166
+ };
167
+
135
168
  type MCPConfigMap = {
136
169
  [name: string]: MCPServerConfig;
137
170
  };
@@ -337,13 +370,18 @@ export class AcpAgent implements Agent {
337
370
  #disposePromise: Promise<void> | undefined;
338
371
  #cleanupRegistered = false;
339
372
  #clientCapabilities: ClientCapabilities | undefined;
373
+ #cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
340
374
 
341
- constructor(connection: AgentSideConnection, initialSession: AgentSession, createSession: CreateAcpSession) {
375
+ constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession) {
342
376
  this.#connection = connection;
343
377
  this.#initialSession = initialSession;
344
378
  this.#createSession = createSession;
345
379
  }
346
380
 
381
+ setCancelCleanupTimeoutForTesting(timeoutMs: number): void {
382
+ this.#cancelCleanupTimeoutMs = Math.max(1, timeoutMs);
383
+ }
384
+
347
385
  async initialize(params: InitializeRequest): Promise<InitializeResponse> {
348
386
  this.#registerConnectionCleanup();
349
387
  this.#clientCapabilities = params.clientCapabilities;
@@ -546,9 +584,15 @@ export class AcpAgent implements Agent {
546
584
  throw new Error("ACP prompt already in progress for this session");
547
585
  }
548
586
  return await this.#queuePrompt(record, async () => {
549
- const queuedTurn = record.promptTurn;
550
- if (queuedTurn && !queuedTurn.settled) {
551
- await queuedTurn.promise.catch(() => undefined);
587
+ const previousTurn = record.promptTurn;
588
+ if (previousTurn) {
589
+ // Wait for any prompt that's still settling or whose cancel cleanup is
590
+ // still in flight. We deliberately swallow the prompt rejection (the
591
+ // owning caller already received it) but let cleanup rejections
592
+ // propagate — a timed-out cancel must fail this queued prompt instead
593
+ // of letting it run on a session that is about to be closed.
594
+ await previousTurn.promise.catch(() => undefined);
595
+ await previousTurn.cleanup;
552
596
  }
553
597
 
554
598
  const converted = this.#convertPromptBlocks(params.prompt);
@@ -557,6 +601,7 @@ export class AcpAgent implements Agent {
557
601
  userMessageId: params.messageId ?? crypto.randomUUID(),
558
602
  cancelRequested: false,
559
603
  settled: false,
604
+ cleanup: undefined,
560
605
  usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
561
606
  unsubscribe: undefined,
562
607
  resolve: pendingPrompt.resolve,
@@ -604,7 +649,7 @@ export class AcpAgent implements Agent {
604
649
  const builtinResult = await executeAcpBuiltinSlashCommand(text, {
605
650
  session: record.session,
606
651
  sessionManager: record.session.sessionManager,
607
- settings: Settings.instance,
652
+ settings: record.session.settings,
608
653
  cwd: record.session.sessionManager.getCwd(),
609
654
  output: output => this.#emitCommandOutput(record, output),
610
655
  refreshCommands: () => this.#emitAvailableCommandsUpdate(record),
@@ -676,16 +721,53 @@ export class AcpAgent implements Agent {
676
721
  if (!promptTurn || promptTurn.settled) {
677
722
  return;
678
723
  }
679
- promptTurn.cancelRequested = true;
724
+ const cleanup = this.#beginCancelCleanup(record, promptTurn);
680
725
  try {
681
- await record.session.abort();
682
- this.#finishPrompt(record, {
683
- stopReason: "cancelled",
684
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
685
- userMessageId: promptTurn.userMessageId,
686
- });
726
+ await cleanup;
687
727
  } catch (error: unknown) {
688
- this.#finishPrompt(record, undefined, error);
728
+ logger.warn("ACP cancel cleanup timed out; closing session", { sessionId: record.session.sessionId, error });
729
+ await this.#closeManagedSession(record.session.sessionId, record);
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Transition a still-running turn into cancellation: mark intent, drop the live-event
735
+ * subscription, start the bounded `abort()` race, and resolve the ACP prompt response
736
+ * with `stopReason: "cancelled"` so the client sees acceptance immediately. The
737
+ * returned promise is the cleanup barrier — it resolves when `abort()` completes and
738
+ * rejects when the timeout fires. Idempotent: a second call returns the same barrier.
739
+ */
740
+ #beginCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
741
+ if (promptTurn.cleanup) {
742
+ return promptTurn.cleanup;
743
+ }
744
+ promptTurn.cancelRequested = true;
745
+ promptTurn.unsubscribe?.();
746
+ const cleanup = this.#runCancelCleanup(record, promptTurn);
747
+ promptTurn.cleanup = cleanup;
748
+ this.#finishPrompt(record, {
749
+ stopReason: "cancelled",
750
+ usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
751
+ userMessageId: promptTurn.userMessageId,
752
+ });
753
+ return cleanup;
754
+ }
755
+
756
+ async #runCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
757
+ let timer: NodeJS.Timeout | undefined;
758
+ const timeout = new Promise<never>((_, reject) => {
759
+ timer = setTimeout(() => reject(new Error("ACP cancel cleanup timed out")), this.#cancelCleanupTimeoutMs);
760
+ });
761
+ try {
762
+ await Promise.race([record.session.abort(), timeout]);
763
+ } finally {
764
+ if (timer) clearTimeout(timer);
765
+ // Order matters: clear `cleanup` before evicting the slot so the slot-eviction
766
+ // branch matches what `#finishPrompt` saw if it ran first.
767
+ promptTurn.cleanup = undefined;
768
+ if (promptTurn.settled && record.promptTurn === promptTurn) {
769
+ record.promptTurn = undefined;
770
+ }
689
771
  }
690
772
  }
691
773
 
@@ -739,6 +821,9 @@ export class AcpAgent implements Agent {
739
821
  case "_omp/usage": {
740
822
  const [firstRecord] = this.#sessions.values();
741
823
  const target = firstRecord?.session ?? this.#initialSession;
824
+ if (!target) {
825
+ return { reports: [] };
826
+ }
742
827
  const reports = await target.fetchUsageReports();
743
828
  return { reports: reports ?? [] };
744
829
  }
@@ -891,6 +976,7 @@ export class AcpAgent implements Agent {
891
976
  promptQueue: { promise: Promise.resolve(), release: undefined },
892
977
  liveMessageId: undefined,
893
978
  liveMessageProgress: undefined,
979
+ toolArgsById: new Map(),
894
980
  extensionsConfigured: false,
895
981
  lifetimeUnsubscribe: undefined,
896
982
  };
@@ -929,8 +1015,7 @@ export class AcpAgent implements Agent {
929
1015
  async #resolveForkSourceSessionPath(sessionId: string): Promise<string> {
930
1016
  const loaded = this.#sessions.get(sessionId);
931
1017
  if (loaded) {
932
- const promptTurn = loaded.promptTurn;
933
- if (promptTurn && !promptTurn.settled) {
1018
+ if (isPromptTurnInFlight(loaded.promptTurn)) {
934
1019
  throw new Error(`ACP session fork is unavailable while a prompt is in progress: ${sessionId}`);
935
1020
  }
936
1021
  await loaded.session.sessionManager.flush();
@@ -950,23 +1035,31 @@ export class AcpAgent implements Agent {
950
1035
 
951
1036
  async #handlePromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
952
1037
  const promptTurn = record.promptTurn;
953
- if (!promptTurn || promptTurn.settled) {
1038
+ if (!promptTurn || promptTurn.settled || promptTurn.cancelRequested) {
954
1039
  return;
955
1040
  }
956
1041
 
1042
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
1043
+ record.toolArgsById.set(event.toolCallId, event.args);
1044
+ }
1045
+
957
1046
  this.#prepareLiveAssistantMessage(record, event);
958
1047
  for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, record.session.sessionId, {
959
1048
  getMessageId: message => this.#getLiveMessageId(record, message),
960
1049
  getMessageProgress: message => this.#getLiveMessageProgress(record, message),
1050
+ getToolArgs: toolCallId => record.toolArgsById.get(toolCallId),
961
1051
  cwd: record.session.sessionManager.getCwd(),
962
1052
  })) {
963
1053
  await this.#connection.sessionUpdate(notification);
964
1054
  }
1055
+ if (event.type === "tool_execution_end") {
1056
+ record.toolArgsById.delete(event.toolCallId);
1057
+ }
965
1058
  this.#clearLiveAssistantMessageAfterEvent(record, event);
966
1059
 
967
1060
  if (event.type === "agent_end") {
968
1061
  await this.#emitEndOfTurnUpdates(record);
969
- await record.session.waitForIdle();
1062
+ await this.#waitForAcpPromptIdle(record);
970
1063
  this.#finishPrompt(record, {
971
1064
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
972
1065
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -975,6 +1068,20 @@ export class AcpAgent implements Agent {
975
1068
  }
976
1069
  }
977
1070
 
1071
+ async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1072
+ for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1073
+ await record.session.waitForIdle();
1074
+ const delivered = await record.session.drainAsyncJobDeliveriesForAcp({
1075
+ timeoutMs: ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS,
1076
+ });
1077
+ if (!delivered) {
1078
+ return;
1079
+ }
1080
+ }
1081
+
1082
+ await record.session.waitForIdle();
1083
+ }
1084
+
978
1085
  #prepareLiveAssistantMessage(record: ManagedSessionRecord, event: AgentSessionEvent): void {
979
1086
  if (
980
1087
  (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") &&
@@ -1019,7 +1126,11 @@ export class AcpAgent implements Agent {
1019
1126
  }
1020
1127
  promptTurn.settled = true;
1021
1128
  promptTurn.unsubscribe?.();
1022
- record.promptTurn = undefined;
1129
+ // Keep the slot occupied until cancel cleanup finishes — `#runCancelCleanup`
1130
+ // evicts the slot in its finally block once both flags say it's safe.
1131
+ if (!promptTurn.cleanup && record.promptTurn === promptTurn) {
1132
+ record.promptTurn = undefined;
1133
+ }
1023
1134
  if (error !== undefined) {
1024
1135
  promptTurn.reject(error);
1025
1136
  return;
@@ -1227,7 +1338,7 @@ export class AcpAgent implements Agent {
1227
1338
 
1228
1339
  #getAvailableModes(session: AgentSession): Array<{ id: string; name: string; description: string }> {
1229
1340
  const modes = [{ id: ACP_DEFAULT_MODE_ID, name: "Default", description: "Standard ACP headless mode" }];
1230
- if (Settings.instance.get("plan.enabled")) {
1341
+ if (session.settings.get("plan.enabled")) {
1231
1342
  modes.push({
1232
1343
  id: ACP_PLAN_MODE_ID,
1233
1344
  name: "Plan",
@@ -1511,16 +1622,30 @@ export class AcpAgent implements Agent {
1511
1622
 
1512
1623
  async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
1513
1624
  const cwd = record.session.sessionManager.getCwd();
1625
+ const replayedToolCallIds = new Set<string>();
1626
+ const replayedToolCallArgs = new Map<string, unknown>();
1514
1627
  for (const message of record.session.sessionManager.buildSessionContext().messages as ReplayableMessage[]) {
1515
- for (const notification of this.#messageToReplayNotifications(record.session.sessionId, message, cwd)) {
1628
+ for (const notification of this.#messageToReplayNotifications(
1629
+ record.session.sessionId,
1630
+ message,
1631
+ cwd,
1632
+ replayedToolCallIds,
1633
+ replayedToolCallArgs,
1634
+ )) {
1516
1635
  await this.#connection.sessionUpdate(notification);
1517
1636
  }
1518
1637
  }
1519
1638
  }
1520
1639
 
1521
- #messageToReplayNotifications(sessionId: string, message: ReplayableMessage, cwd: string): SessionNotification[] {
1640
+ #messageToReplayNotifications(
1641
+ sessionId: string,
1642
+ message: ReplayableMessage,
1643
+ cwd: string,
1644
+ replayedToolCallIds: Set<string>,
1645
+ replayedToolCallArgs: Map<string, unknown>,
1646
+ ): SessionNotification[] {
1522
1647
  if (message.role === "assistant") {
1523
- return this.#replayAssistantMessage(sessionId, message);
1648
+ return this.#replayAssistantMessage(sessionId, message, cwd, replayedToolCallIds, replayedToolCallArgs);
1524
1649
  }
1525
1650
  if (
1526
1651
  message.role === "user" ||
@@ -1540,11 +1665,19 @@ export class AcpAgent implements Agent {
1540
1665
  typeof message.toolCallId === "string" &&
1541
1666
  typeof message.toolName === "string"
1542
1667
  ) {
1543
- return this.#replayToolResult(sessionId, cwd, {
1544
- ...message,
1545
- toolCallId: message.toolCallId,
1546
- toolName: message.toolName,
1547
- });
1668
+ return this.#replayToolResult(
1669
+ sessionId,
1670
+ cwd,
1671
+ {
1672
+ ...message,
1673
+ toolCallId: message.toolCallId,
1674
+ toolName: message.toolName,
1675
+ },
1676
+ {
1677
+ includeStart: !replayedToolCallIds.has(message.toolCallId),
1678
+ toolArgs: replayedToolCallArgs.get(message.toolCallId),
1679
+ },
1680
+ );
1548
1681
  }
1549
1682
  if (
1550
1683
  message.role === "bashExecution" ||
@@ -1561,7 +1694,13 @@ export class AcpAgent implements Agent {
1561
1694
  return [];
1562
1695
  }
1563
1696
 
1564
- #replayAssistantMessage(sessionId: string, message: ReplayableMessage): SessionNotification[] {
1697
+ #replayAssistantMessage(
1698
+ sessionId: string,
1699
+ message: ReplayableMessage,
1700
+ cwd: string,
1701
+ replayedToolCallIds: Set<string>,
1702
+ replayedToolCallArgs: Map<string, unknown>,
1703
+ ): SessionNotification[] {
1565
1704
  const notifications: SessionNotification[] = [];
1566
1705
  const messageId = crypto.randomUUID();
1567
1706
  if (Array.isArray(message.content)) {
@@ -1596,24 +1735,23 @@ export class AcpAgent implements Agent {
1596
1735
  });
1597
1736
  continue;
1598
1737
  }
1738
+ const toolItem = item as ReplayableToolItem;
1599
1739
  if (
1600
- (item.type === "toolCall" || item.type === "tool_use") &&
1601
- "id" in item &&
1602
- typeof item.id === "string" &&
1603
- "name" in item &&
1604
- typeof item.name === "string"
1740
+ (toolItem.type === "toolCall" || toolItem.type === "tool_use") &&
1741
+ typeof toolItem.id === "string" &&
1742
+ typeof toolItem.name === "string"
1605
1743
  ) {
1606
- const update: SessionUpdate = {
1607
- sessionUpdate: "tool_call",
1608
- toolCallId: item.id,
1609
- title: item.name,
1610
- kind: mapToolKind(item.name),
1744
+ const args = this.#buildReplayAssistantToolArgs(toolItem);
1745
+ const update = buildToolCallStartUpdate({
1746
+ toolCallId: toolItem.id,
1747
+ toolName: toolItem.name,
1748
+ args,
1611
1749
  status: "completed",
1612
- };
1613
- if ("arguments" in item && typeof item.arguments === "string") {
1614
- update.rawInput = item.arguments;
1615
- }
1750
+ cwd,
1751
+ });
1616
1752
  notifications.push({ sessionId, update });
1753
+ replayedToolCallIds.add(toolItem.id);
1754
+ replayedToolCallArgs.set(toolItem.id, args);
1617
1755
  }
1618
1756
  }
1619
1757
  }
@@ -1630,10 +1768,21 @@ export class AcpAgent implements Agent {
1630
1768
  return notifications;
1631
1769
  }
1632
1770
 
1771
+ #buildReplayAssistantToolArgs(item: ReplayableToolItem): unknown {
1772
+ if ("arguments" in item) {
1773
+ return normalizeReplayToolArguments(item.arguments).args;
1774
+ }
1775
+ if (item.type === "tool_use" && "input" in item) {
1776
+ return item.input;
1777
+ }
1778
+ return {};
1779
+ }
1780
+
1633
1781
  #replayToolResult(
1634
1782
  sessionId: string,
1635
1783
  cwd: string,
1636
1784
  message: Required<Pick<ReplayableMessage, "toolCallId" | "toolName">> & ReplayableMessage,
1785
+ options: { includeStart?: boolean; toolArgs?: unknown } = {},
1637
1786
  ): SessionNotification[] {
1638
1787
  const args = this.#buildReplayToolArgs(message.details);
1639
1788
  const startEvent: AgentSessionEvent = {
@@ -1653,10 +1802,14 @@ export class AcpAgent implements Agent {
1653
1802
  errorMessage: message.errorMessage,
1654
1803
  },
1655
1804
  };
1656
- return [
1657
- ...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId, { cwd }),
1658
- ...mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId, { cwd }),
1659
- ];
1805
+ const notifications = mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId, {
1806
+ cwd,
1807
+ getToolArgs: toolCallId => (toolCallId === message.toolCallId ? options.toolArgs : undefined),
1808
+ });
1809
+ if (options.includeStart === false) {
1810
+ return notifications;
1811
+ }
1812
+ return [...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId, { cwd }), ...notifications];
1660
1813
  }
1661
1814
 
1662
1815
  #buildReplayToolArgs(details: unknown): { path?: string } {
@@ -1887,22 +2040,15 @@ export class AcpAgent implements Agent {
1887
2040
 
1888
2041
  async #cancelPromptForClose(record: ManagedSessionRecord): Promise<void> {
1889
2042
  const promptTurn = record.promptTurn;
1890
- if (!promptTurn || promptTurn.settled) {
2043
+ if (!isPromptTurnInFlight(promptTurn)) {
1891
2044
  return;
1892
2045
  }
1893
-
1894
- promptTurn.cancelRequested = true;
1895
- promptTurn.unsubscribe?.();
2046
+ const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
1896
2047
  try {
1897
- await record.session.abort();
2048
+ await cleanup;
1898
2049
  } catch (error) {
1899
2050
  logger.warn("Failed to abort ACP prompt during session close", { error });
1900
2051
  }
1901
- this.#finishPrompt(record, {
1902
- stopReason: "cancelled",
1903
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
1904
- userMessageId: promptTurn.userMessageId,
1905
- });
1906
2052
  }
1907
2053
 
1908
2054
  async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
@@ -36,7 +36,7 @@ export function createAcpClientBridge(
36
36
  requestPermission: true,
37
37
  };
38
38
 
39
- const bridge: ClientBridge = { capabilities };
39
+ const bridge: ClientBridge = { capabilities, deferAgentInitiatedTurns: true };
40
40
 
41
41
  if (capabilities.readTextFile) {
42
42
  bridge.readTextFile = async params => {
@@ -122,6 +122,7 @@ async function requestPermission(
122
122
  toolCallId: toolCall.toolCallId,
123
123
  title: toolCall.title,
124
124
  ...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}),
125
+ ...(toolCall.status ? { status: toolCall.status as ToolCallUpdate["status"] } : {}),
125
126
  ...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}),
126
127
  ...(toolCall.locations ? { locations: toolCall.locations } : {}),
127
128
  };