@agentclientprotocol/claude-agent-acp 0.99.2-preview.1 → 0.99.2-preview.11

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 (42) hide show
  1. package/README.md +8 -0
  2. package/dist/acp-agent.d.ts +60 -18
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +325 -100
  5. package/dist/diff.d.ts +1 -1
  6. package/dist/diff.js +5 -5
  7. package/dist/elicitation.d.ts +1 -2
  8. package/dist/elicitation.d.ts.map +1 -1
  9. package/dist/elicitation.js +34 -16
  10. package/dist/fork-session.d.ts.map +1 -1
  11. package/dist/fork-session.js +23 -0
  12. package/dist/native-subagents.d.ts +49 -3
  13. package/dist/native-subagents.d.ts.map +1 -1
  14. package/dist/native-subagents.js +118 -4
  15. package/dist/session-index/service.d.ts +3 -1
  16. package/dist/session-index/service.d.ts.map +1 -1
  17. package/dist/session-index/service.js +5 -10
  18. package/dist/startup-failure.d.ts +16 -0
  19. package/dist/startup-failure.d.ts.map +1 -0
  20. package/dist/startup-failure.js +38 -0
  21. package/dist/subagent-history.d.ts +8 -1
  22. package/dist/subagent-history.d.ts.map +1 -1
  23. package/dist/subagent-history.js +40 -1
  24. package/dist/subagent-replay.d.ts +19 -3
  25. package/dist/subagent-replay.d.ts.map +1 -1
  26. package/dist/subagent-replay.js +46 -4
  27. package/dist/tool-calls/reporters/interaction.d.ts.map +1 -1
  28. package/dist/tool-calls/reporters/interaction.js +8 -3
  29. package/dist/v2/agent.d.ts.map +1 -1
  30. package/dist/v2/agent.js +42 -17
  31. package/dist/v2/initialize.d.ts +16 -1
  32. package/dist/v2/initialize.d.ts.map +1 -1
  33. package/dist/v2/initialize.js +26 -3
  34. package/dist/v2/permission.d.ts +5 -2
  35. package/dist/v2/permission.d.ts.map +1 -1
  36. package/dist/v2/permission.js +5 -2
  37. package/dist/v2/prompt.d.ts +2 -1
  38. package/dist/v2/prompt.d.ts.map +1 -1
  39. package/dist/v2/prompt.js +5 -1
  40. package/dist/v2/session.d.ts.map +1 -1
  41. package/dist/v2/session.js +21 -9
  42. package/package.json +6 -6
package/dist/acp-agent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { agent as acpAgent, CreateElicitationResponse, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk/unstable";
1
+ import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk/unstable";
2
2
  import { filterEscalatingDefaultMode, getSessionMessages, listSessions, query, resolveSettings, } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
4
4
  import { blobResourceToClaude } from "./blob-resources.js";
@@ -55,6 +55,7 @@ import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableMode
55
55
  import { GatewayListingError, listBedrockModels, listGatewayModels, PROVIDER_AUTH_TOKEN_PLACEHOLDER, selectBedrockRegion, } from "./gateway-models.js";
56
56
  import { buildEffortConfigOption, EFFORT_CONFIG_ID, effortFlagSettings, effortLevelName, mergeEffortSettings, settingsEffortForModel, } from "./session-effort.js";
57
57
  import { createUltracodeConfigOption, readUltracodeSettings, resolveUltracodeEnabled, ULTRACODE_CONFIG_ID, ultracodeSupported, ultracodeWorkflowsEnabled, } from "./session-ultracode.js";
58
+ import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "./startup-failure.js";
58
59
  export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
59
60
  export { applyAvailableModelsAllowlist, matchResumedModel, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
60
61
  const execFileAsync = promisify(execFile);
@@ -512,6 +513,16 @@ export function computeSessionFingerprint(params) {
512
513
  options,
513
514
  }));
514
515
  }
516
+ /** `params` without `_meta.claudeCode.options.effort`. */
517
+ function withoutEffort(params) {
518
+ const meta = params._meta;
519
+ if (meta?.claudeCode?.options?.effort === undefined) {
520
+ return params;
521
+ }
522
+ const options = { ...meta.claudeCode.options };
523
+ delete options.effort;
524
+ return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
525
+ }
515
526
  /** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
516
527
  * cannot drift from the errors this agent throws. */
517
528
  const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
@@ -944,6 +955,7 @@ class ClientConnection {
944
955
  requestPermission(params, signal) {
945
956
  return this.ctx.request(methods.client.session.requestPermission, params, {
946
957
  cancellationSignal: signal,
958
+ stopWaitingOnCancel: true,
947
959
  });
948
960
  }
949
961
  readTextFile(params) {
@@ -955,6 +967,7 @@ class ClientConnection {
955
967
  createElicitation(params, signal) {
956
968
  return this.ctx.request(methods.client.elicitation.create, params, {
957
969
  cancellationSignal: signal,
970
+ stopWaitingOnCancel: true,
958
971
  });
959
972
  }
960
973
  completeElicitation(params) {
@@ -1110,7 +1123,10 @@ async function authenticateMcpServer(host, sessionId, query, serverName) {
1110
1123
  completed.then((authenticated) => ({ type: "completed", authenticated })),
1111
1124
  elicitation.then((response) => ({ type: "elicitation", response })),
1112
1125
  ]);
1113
- if (first.type === "elicitation" && !CreateElicitationResponse.isAccept(first.response)) {
1126
+ // A url-mode accept carries no content to check. The guard
1127
+ // `CreateElicitationResponse.isAccept` would also reject an accept whose
1128
+ // `content` holds a v2-only value, such as an object.
1129
+ if (first.type === "elicitation" && first.response.action !== "accept") {
1114
1130
  return;
1115
1131
  }
1116
1132
  if (first.type === "elicitation") {
@@ -1159,6 +1175,8 @@ export class ClaudeAcpAgent {
1159
1175
  providerConfig;
1160
1176
  /** Serializes provider changes while every open query is recreated between turns. */
1161
1177
  providerUpdate = null;
1178
+ /** Per session: queue that runs mode/config changes and query restarts one at a time. */
1179
+ configurationChanges = new Map();
1162
1180
  exitPlan;
1163
1181
  /** Last auth identity reported to the client, connection-scoped like
1164
1182
  * `authenticate`/`logout`. Undefined means "not determined yet". */
@@ -1207,6 +1225,12 @@ export class ClaudeAcpAgent {
1207
1225
  replayedChildModels = new Map();
1208
1226
  /** See the `foreground` constructor option. */
1209
1227
  foregroundEvents;
1228
+ /** Counts the prompts `startTurn()` has received, across sessions. */
1229
+ promptsReceived = 0;
1230
+ /** Per session, the `promptsReceived` count when its last cancel started.
1231
+ * A prompt received before that cancel that `startTurn()` had not queued
1232
+ * yet ends as cancelled instead of being queued. */
1233
+ cancelledThrough = new Map();
1210
1234
  /**
1211
1235
  * The foreground work of each session while it runs, by the session id that
1212
1236
  * the client knows, on every connection: only the `foreground` option
@@ -1490,7 +1514,7 @@ export class ClaudeAcpAgent {
1490
1514
  * before the first update. A failed CLI start stops the replay.
1491
1515
  */
1492
1516
  async createSessionWhileReplaying(params, timing) {
1493
- await this.validateCwd(params.cwd);
1517
+ await this.validateCwd(params.cwd, params.additionalDirectories);
1494
1518
  clientSystemPrompt(params.systemPrompt);
1495
1519
  const pending = {
1496
1520
  state: {
@@ -1548,7 +1572,8 @@ export class ClaudeAcpAgent {
1548
1572
  async unstable_forkSession(params) {
1549
1573
  while (this.providerUpdate)
1550
1574
  await this.providerUpdate;
1551
- // Reject a prompt the agent cannot apply before copying the transcript.
1575
+ // Reject a bad `cwd` or prompt before copying the transcript.
1576
+ await this.validateCwd(params.cwd, params.additionalDirectories);
1552
1577
  clientSystemPrompt(params.systemPrompt);
1553
1578
  const { sessionId } = await forkSession(params, {
1554
1579
  liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
@@ -1607,13 +1632,19 @@ export class ClaudeAcpAgent {
1607
1632
  * reads the start and the end only of the transcripts of the page.
1608
1633
  */
1609
1634
  async listSessions(params) {
1635
+ if (params.cwd != null && !path.isAbsolute(params.cwd)) {
1636
+ throw RequestError.invalidParams({ cwd: params.cwd }, `\`cwd\` must be an absolute path, but received: ${params.cwd}`);
1637
+ }
1610
1638
  const indexed = this.sessionIndex.list(params);
1611
1639
  if (indexed)
1612
1640
  return indexed;
1613
1641
  const offset = sessionListOffset(params.cursor);
1614
1642
  // One more session than the page tells whether a next page exists.
1643
+ // `includeWorktrees: false`: a `cwd` filter matches sessions of exactly
1644
+ // that directory, not of the other git worktrees of its repository.
1615
1645
  const sdkSessions = await listSessions({
1616
1646
  dir: params.cwd ?? undefined,
1647
+ includeWorktrees: false,
1617
1648
  limit: SESSION_LIST_PAGE_SIZE + 1,
1618
1649
  offset,
1619
1650
  });
@@ -1689,7 +1720,7 @@ export class ClaudeAcpAgent {
1689
1720
  this.setAuthStatus(gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayAuthRequest)?.baseUrl));
1690
1721
  return;
1691
1722
  }
1692
- throw new Error("Method not implemented.");
1723
+ throw RequestError.invalidParams({ methodId: _params.methodId }, `Unsupported authentication method: ${_params.methodId}`);
1693
1724
  }
1694
1725
  /**
1695
1726
  * Stores `next` and pushes it to the client.
@@ -2131,8 +2162,18 @@ export class ClaudeAcpAgent {
2131
2162
  /**
2132
2163
  * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
2133
2164
  * outcome of a turn is field for field a v1 prompt response.
2165
+ *
2166
+ * A content block that the agent does not advertise, such as `audio`, fails
2167
+ * the request with invalid params (-32602) before anything is queued:
2168
+ * `promptToClaude` would drop it. v2 prompts reach `startTurn` through
2169
+ * `v1PromptRequest`, which rejects these blocks itself.
2134
2170
  */
2135
2171
  async prompt(params) {
2172
+ for (const block of params.prompt) {
2173
+ if (!SUPPORTED_PROMPT_CONTENT_TYPES.includes(block.type)) {
2174
+ throw RequestError.invalidParams({ type: block.type }, `Content blocks of type ${block.type} are not supported`);
2175
+ }
2176
+ }
2136
2177
  let events;
2137
2178
  const outcome = new Promise((resolve, reject) => {
2138
2179
  events = { inserted() { }, ended: resolve, failed: reject };
@@ -2147,6 +2188,7 @@ export class ClaudeAcpAgent {
2147
2188
  * before this resolves.
2148
2189
  */
2149
2190
  async startTurn(params, events) {
2191
+ const received = ++this.promptsReceived;
2150
2192
  while (this.providerUpdate)
2151
2193
  await this.providerUpdate;
2152
2194
  let session = this.sessions[params.sessionId];
@@ -2164,8 +2206,16 @@ export class ClaudeAcpAgent {
2164
2206
  this.useAuthStatusSettings(session.settingsManager, session.effortSettingsOverride, session.creationParams?._meta?.claudeCode?.options?.env);
2165
2207
  void this.probeCliAuthStatus();
2166
2208
  const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
2167
- if (signOutRespawn)
2168
- session = await signOutRespawn;
2209
+ if (signOutRespawn) {
2210
+ await signOutRespawn;
2211
+ // A provider update can queue its replacement behind the sign-out respawn.
2212
+ // Wait for that replacement too before a prompt starts on the old route.
2213
+ while (this.providerUpdate)
2214
+ await this.providerUpdate;
2215
+ session = this.sessions[params.sessionId];
2216
+ if (!session)
2217
+ throw RequestError.sessionNotFound(params.sessionId);
2218
+ }
2169
2219
  // The SDK query stream already terminated (see `queryClosed`); its iterator
2170
2220
  // can't be revived, so enqueueing here would hang on a deferred that never
2171
2221
  // settles. Fail clearly and let the client start a fresh session.
@@ -2252,7 +2302,9 @@ export class ClaudeAcpAgent {
2252
2302
  // its consumer is gone, so the prompt would never be inserted. End it as
2253
2303
  // the close ends a queued turn: `cancelled` on v1, -32800 on v2. A stream
2254
2304
  // that ended meanwhile fails the prompt as the check above does.
2255
- if (session.tornDown) {
2305
+ // A `session/cancel` that arrived after this prompt, during the awaits
2306
+ // above, ends it the same way.
2307
+ if (session.tornDown || received <= (this.cancelledThrough.get(params.sessionId) ?? 0)) {
2256
2308
  session.fileChangeReporter?.finish(fileChangeReport, "cancelled");
2257
2309
  events.ended({ stopReason: "cancelled" });
2258
2310
  return;
@@ -2266,6 +2318,7 @@ export class ClaudeAcpAgent {
2266
2318
  // the consumer is running.
2267
2319
  let completeTurn;
2268
2320
  const turn = {
2321
+ received,
2269
2322
  promptUuid,
2270
2323
  events,
2271
2324
  isLocalOnlyCommand,
@@ -2457,36 +2510,29 @@ export class ClaudeAcpAgent {
2457
2510
  });
2458
2511
  }
2459
2512
  }
2460
- async recreateSignedOutQuery(sessionId, session) {
2461
- const creationParams = session.creationParams;
2462
- if (!creationParams) {
2463
- throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
2464
- }
2465
- this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
2466
- // Already closed by `markSessionForSignOutRespawn`; idempotent here so a
2467
- // husk that reached this by another route still releases its resources.
2468
- this.closeQueryStream(session);
2469
- try {
2470
- // The very first turn may be the one that signed out, so the CLI may
2471
- // never have written the conversation.
2472
- await this.recreateLiveSession(creationParams, sessionId, {
2473
- permissionMode: session.modes.currentModeId,
2474
- });
2475
- // The new Session has the same MCP servers; keep the ones already
2476
- // reported so its first `system`/init doesn't repeat their notices.
2477
- const recreated = this.sessions[sessionId];
2478
- if (recreated && session.reportedMcpServers) {
2479
- recreated.reportedMcpServers = new Set(session.reportedMcpServers);
2513
+ /** Queued per session, so a choice sent during the respawn lands on the new query. */
2514
+ recreateSignedOutQuery(sessionId, session) {
2515
+ return this.serializeConfigurationChange(sessionId, async () => {
2516
+ const creationParams = session.creationParams;
2517
+ if (!creationParams) {
2518
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
2480
2519
  }
2481
- }
2482
- catch (error) {
2483
- // Keep the husk addressable: the client answers the refusal with its own
2484
- // auth flow and then retries this session.
2485
- if (!this.sessions[sessionId]) {
2486
- this.sessions[sessionId] = session;
2520
+ // Already replaced by a provider restart, or closed.
2521
+ if (this.sessions[sessionId] !== session)
2522
+ return;
2523
+ this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
2524
+ try {
2525
+ // The signed-out turn may be the first, so the conversation may not be saved yet.
2526
+ await this.replaceSessionQuery(sessionId, session, creationParams);
2487
2527
  }
2488
- throw error;
2489
- }
2528
+ catch (error) {
2529
+ // Keep the husk so the client can sign in and retry; a closed session stays closed.
2530
+ if (!session.tornDown && !this.sessions[sessionId]) {
2531
+ this.sessions[sessionId] = session;
2532
+ }
2533
+ throw error;
2534
+ }
2535
+ });
2490
2536
  }
2491
2537
  /**
2492
2538
  * Starts a new query for the session `sessionId`, which ran on this
@@ -2510,6 +2556,7 @@ export class ClaudeAcpAgent {
2510
2556
  return await this.createSession(params, {
2511
2557
  reuseSessionId: sessionId,
2512
2558
  permissionMode: options.permissionMode,
2559
+ replacingSession: options.replacingSession,
2513
2560
  });
2514
2561
  }
2515
2562
  }
@@ -6442,6 +6489,12 @@ export class ClaudeAcpAgent {
6442
6489
  return;
6443
6490
  }
6444
6491
  session.cancelled = true;
6492
+ // Only prompts received before this cancel are cancelled. A prompt that
6493
+ // arrives while the awaits below run is queued and runs after the cancel
6494
+ // (ACP v2: a `session/prompt` received after `session/cancel` is not
6495
+ // cancelled by it).
6496
+ const cancelledThrough = this.promptsReceived;
6497
+ this.cancelledThrough.set(params.sessionId, cancelledThrough);
6445
6498
  // The interrupt below makes Claude Code withdraw the requests that are open
6446
6499
  // now (see `withPendingUserInput`).
6447
6500
  for (const input of session.openUserInputs ?? [])
@@ -6450,7 +6503,7 @@ export class ClaudeAcpAgent {
6450
6503
  // the meantime is already judged against these prompts.
6451
6504
  session.cancelledPrompts ??= new Set();
6452
6505
  for (const turn of session.turnQueue ?? []) {
6453
- if (turn.settled)
6506
+ if (turn.settled || turn.received > cancelledThrough)
6454
6507
  continue;
6455
6508
  session.cancelledPrompts.add(turn.promptUuid);
6456
6509
  for (const uuid of turn.steeredUuids ?? [])
@@ -6525,7 +6578,7 @@ export class ClaudeAcpAgent {
6525
6578
  const orphanedTurns = [];
6526
6579
  if (session.turnQueue) {
6527
6580
  for (const turn of session.turnQueue) {
6528
- if (turn !== session.activeTurn && !turn.settled) {
6581
+ if (turn !== session.activeTurn && !turn.settled && turn.received <= cancelledThrough) {
6529
6582
  session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
6530
6583
  turn.settled = true;
6531
6584
  // Deliberately no `usage`: a queued turn never ran, so the session
@@ -6579,7 +6632,7 @@ export class ClaudeAcpAgent {
6579
6632
  }
6580
6633
  this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
6581
6634
  }
6582
- session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
6635
+ session.turnQueue = session.turnQueue.filter((turn) => !turn.settled);
6583
6636
  }
6584
6637
  const diagnosticTurns = orphanedTurns.filter((turn) => {
6585
6638
  if (turn.commandFinished === "completed" ||
@@ -6702,8 +6755,8 @@ export class ClaudeAcpAgent {
6702
6755
  }
6703
6756
  // Before the interrupt: once it stops the running turn, Claude Code starts
6704
6757
  // the next queued message right away.
6705
- // A prompt or steer that arrived while this cancel awaited is abandoned
6706
- // too, after the record above was taken.
6758
+ // A steer that arrived while this cancel awaited is abandoned too, after
6759
+ // the record above was taken.
6707
6760
  for (const uuid of abandoned)
6708
6761
  (session.cancelledPrompts ??= new Set()).add(uuid);
6709
6762
  this.dropQueuedMessages(params.sessionId, session.query, abandoned);
@@ -6853,6 +6906,7 @@ export class ClaudeAcpAgent {
6853
6906
  session.nativeSubagentRuntime?.clear();
6854
6907
  session.asyncTaskRuntime?.clear();
6855
6908
  this.replayedChildModels.delete(sessionId);
6909
+ this.cancelledThrough.delete(sessionId);
6856
6910
  delete this.sessions[sessionId];
6857
6911
  this.sessionIndex.onTeardown(sessionId);
6858
6912
  const ended = await raceTimeoutAndAbort(turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, new AbortController().signal);
@@ -6895,10 +6949,13 @@ export class ClaudeAcpAgent {
6895
6949
  async deleteSession(params) {
6896
6950
  return this.sessionIndex.deleteSession(params);
6897
6951
  }
6898
- async setSessionMode(params) {
6899
- return this.sessionModes.setSessionMode(params);
6952
+ setSessionMode(params) {
6953
+ return this.serializeConfigurationChange(params.sessionId, () => this.sessionModes.setSessionMode(params));
6954
+ }
6955
+ setSessionConfigOption(params) {
6956
+ return this.serializeConfigurationChange(params.sessionId, () => this.selectSessionConfigOption(params));
6900
6957
  }
6901
- async setSessionConfigOption(params) {
6958
+ async selectSessionConfigOption(params) {
6902
6959
  const session = this.sessions[params.sessionId];
6903
6960
  if (!session) {
6904
6961
  throw RequestError.sessionNotFound(params.sessionId);
@@ -6914,6 +6971,13 @@ export class ClaudeAcpAgent {
6914
6971
  if (!option) {
6915
6972
  throw RequestError.invalidParams(undefined, `Unknown config option: ${params.configId}`);
6916
6973
  }
6974
+ // The value must match the type the option was advertised with: a
6975
+ // `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a
6976
+ // `type: "select"` option takes a string.
6977
+ const expectedType = option.type === "boolean" ? "boolean" : "string";
6978
+ if (typeof params.value !== expectedType) {
6979
+ throw RequestError.invalidParams({ configId: params.configId, value: params.value }, `Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`);
6980
+ }
6917
6981
  // Fast mode and ultracode carry a boolean value (for Clients that opted
6918
6982
  // into boolean config options) or the "on"/"off" select fallback, so they
6919
6983
  // bypass the
@@ -7131,7 +7195,7 @@ export class ClaudeAcpAgent {
7131
7195
  ? await subagentHistory(sessionId)
7132
7196
  : { agents: new Map() };
7133
7197
  const rfdReplay = form === "rfd"
7134
- ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.subagentModels(sessionId), !pending)
7198
+ ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.subagentModels(sessionId), !pending, session?.cwd)
7135
7199
  : undefined;
7136
7200
  rfdReplay?.scan(messages, sessionId);
7137
7201
  // History persists no task_started frame, so a background task gets its
@@ -7678,10 +7742,11 @@ export class ClaudeAcpAgent {
7678
7742
  /** Forward a permission request to the client, wiring the tool call's
7679
7743
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
7680
7744
  * while the client's prompt is still open the signal aborts, the SDK sends
7681
- * `$/cancel_request`, and our local abort race settles even if the client
7682
- * ignores it. A `cancelled` outcome, request rejection, and local abort all
7683
- * surface the same "Tool use aborted" the callers already expect. */
7684
- async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId, agentID, toolInput, previewContent) {
7745
+ * `$/cancel_request` and stops waiting for the client's answer. The
7746
+ * rejection surfaces as the "Tool use aborted" that the callers expect.
7747
+ * `canUseTool` decides what a `cancelled` outcome means. */
7748
+ async requestPermissionFromClient(params, toolName, toolSignal, parentToolUseId, ownerSessionId, agentID, toolInput, previewContent) {
7749
+ const signal = this.untilSessionClosed(ownerSessionId, toolSignal);
7685
7750
  if (signal.aborted)
7686
7751
  throw new Error("Tool use aborted");
7687
7752
  // The SDK may invoke `canUseTool` (and therefore this permission request)
@@ -7693,13 +7758,10 @@ export class ClaudeAcpAgent {
7693
7758
  await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, toolInput, parentToolUseId, signal, params.sessionId, previewContent);
7694
7759
  if (signal.aborted)
7695
7760
  throw new Error("Tool use aborted");
7696
- // Do not rely on every ACP client settling requestPermission after the
7697
- // cancellation signal. The local race guarantees that Claude's tool call
7698
- // is released even when an older or broken client ignores $/cancel_request.
7699
7761
  // The request goes to a subagent's child session for a tool call of that
7700
7762
  // subagent, but it blocks the turn of the owner session.
7701
7763
  try {
7702
- return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput(ownerSessionId, signal, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
7764
+ return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput(ownerSessionId, signal, () => this.client.requestPermission(params, signal), agentID));
7703
7765
  }
7704
7766
  catch (error) {
7705
7767
  if (signal.aborted) {
@@ -7709,15 +7771,23 @@ export class ClaudeAcpAgent {
7709
7771
  }
7710
7772
  }
7711
7773
  /** Send the client an elicitation for a callback of Claude Code. When the
7712
- * callback's `signal` aborts, the SDK sends `$/cancel_request` and keeps
7713
- * waiting for the client's reply, which a client that ignores the cancel
7714
- * never sends. So the call also rejects at once on the abort, as
7715
- * {@link requestPermissionFromClient} does for a permission request. Left
7716
- * open, the request would stay in `openUserInputs` (see
7717
- * {@link withPendingUserInput}), which keeps steering non-interrupting, and
7718
- * a child's `openRequests` would not drop. */
7774
+ * callback's `signal` aborts, the SDK sends `$/cancel_request` and the call
7775
+ * rejects at once, without waiting for a reply that a client ignoring the
7776
+ * cancel never sends. Left open, the request would stay in
7777
+ * `openUserInputs` (see {@link withPendingUserInput}), which keeps steering
7778
+ * non-interrupting, and a child's `openRequests` would not drop. */
7719
7779
  elicitFromClient(request, signal) {
7720
- return raceWithAbort(this.client.createElicitation(request, signal), signal);
7780
+ return this.client.createElicitation(request, signal);
7781
+ }
7782
+ /** `signal`, also aborted when `teardownSession` aborts the
7783
+ * `abortController` of session `sessionId`. Claude Code aborts a callback's
7784
+ * own signal when its query closes, but not necessarily before
7785
+ * `session/close` responds, and v2 requires every pending permission
7786
+ * request and elicitation of the session to be cancelled with
7787
+ * `$/cancel_request` before that response. */
7788
+ untilSessionClosed(sessionId, signal) {
7789
+ const sessionSignal = this.sessions[sessionId]?.abortController?.signal;
7790
+ return sessionSignal ? AbortSignal.any([signal, sessionSignal]) : signal;
7721
7791
  }
7722
7792
  /** Settle the tool call `ensureToolCallEmitted` emitted for a
7723
7793
  * SandboxNetworkAccess request once the request is answered. The callback
@@ -7852,6 +7922,11 @@ export class ClaudeAcpAgent {
7852
7922
  const session = this.sessions[sessionId];
7853
7923
  if (!session || !input.agent_id)
7854
7924
  return { continue: true };
7925
+ // A hook's `cwd` is the child's working directory, e.g. its worktree.
7926
+ // Reported before the hook returns, so before the work that follows it.
7927
+ await session.nativeSubagentRuntime
7928
+ ?.reportCwd(input.agent_id, input.cwd)
7929
+ .catch((error) => this.logger.error(`Session ${sessionId}: failed to report subagent cwd: ${error}`));
7855
7930
  const options = [];
7856
7931
  if (input.permission_mode) {
7857
7932
  const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode);
@@ -8077,9 +8152,13 @@ export class ClaudeAcpAgent {
8077
8152
  // cancel does not stop. Answer such a request with a denial so the
8078
8153
  // subagent reads "the user refused" and goes on. Throwing instead makes
8079
8154
  // Claude Code deny it as "Tool permission request failed".
8155
+ // On ACP v2, `cancelled` also answers a prompt that the user dismissed
8156
+ // or that the client declined by policy. Without a `session/cancel`
8157
+ // (`session.cancelled` unset), that is the user refusing the tool, so it
8158
+ // gets the same denial and the turn goes on.
8080
8159
  if (response.outcome?.outcome === "cancelled" &&
8081
8160
  this.sessions[sessionId] === session &&
8082
- !blocksForeground(session, agentID)) {
8161
+ (!blocksForeground(session, agentID) || (this.v2 && !session.cancelled))) {
8083
8162
  return {
8084
8163
  behavior: "deny",
8085
8164
  message: "User refused permission to run tool",
@@ -8143,7 +8222,8 @@ export class ClaudeAcpAgent {
8143
8222
  * an error, is a `cancel`.
8144
8223
  */
8145
8224
  handleMcpElicitation(sessionId, support) {
8146
- return async (request, { signal }) => {
8225
+ return async (request, { signal: callbackSignal }) => {
8226
+ const signal = this.untilSessionClosed(sessionId, callbackSignal);
8147
8227
  const isUrl = request.mode === "url";
8148
8228
  if ((isUrl && !support.url) || (!isUrl && !support.form)) {
8149
8229
  return { action: "cancel" };
@@ -8180,7 +8260,8 @@ export class ClaudeAcpAgent {
8180
8260
  * The elicitation goes to `requestSessionId`, a subagent's child session for
8181
8261
  * a question it asks, and blocks the turn of `sessionId`.
8182
8262
  */
8183
- async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal, agentID) {
8263
+ async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, toolSignal, agentID) {
8264
+ const signal = this.untilSessionClosed(sessionId, toolSignal);
8184
8265
  const questions = extractAskUserQuestions(toolInput);
8185
8266
  if (!questions) {
8186
8267
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
@@ -8219,7 +8300,8 @@ export class ClaudeAcpAgent {
8219
8300
  * refusal-fallback consent prompt, rendered as an ACP form elicitation.
8220
8301
  */
8221
8302
  handleUserDialog(sessionId) {
8222
- return async (request, { signal }) => {
8303
+ return async (request, { signal: callbackSignal }) => {
8304
+ const signal = this.untilSessionClosed(sessionId, callbackSignal);
8223
8305
  if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
8224
8306
  return { behavior: "cancelled" };
8225
8307
  }
@@ -8323,19 +8405,27 @@ export class ClaudeAcpAgent {
8323
8405
  /**
8324
8406
  * The work that follows a session setup response: the session's commands,
8325
8407
  * the current states of the subagents a load replayed, and MCP server
8326
- * authentication. A v2 client gets the commands in the setup response itself,
8327
- * and the subagent states from the v2 surface once it has sent its own
8328
- * response (`src/v2/agent.ts`), so it gets neither here.
8408
+ * authentication. On v2 the setup handlers of `src/v2/agent.ts` do this
8409
+ * work once they have sent their response, so it does nothing here.
8329
8410
  */
8330
8411
  afterSetupResponse(sessionId, mcpServers) {
8412
+ if (this.v2)
8413
+ return;
8331
8414
  setTimeout(() => {
8332
- if (!this.v2) {
8333
- this.sendAvailableCommandsUpdate(sessionId);
8334
- void this.publishCurrentSubagentStates(sessionId);
8335
- }
8415
+ this.sendAvailableCommandsUpdate(sessionId);
8416
+ void this.publishCurrentSubagentStates(sessionId);
8336
8417
  startMcpAuthentication(this, sessionId, mcpServers);
8337
8418
  }, 0);
8338
8419
  }
8420
+ /**
8421
+ * Starts OAuth in the background for the servers of `mcpServers` that
8422
+ * Claude Code reports as needing it, which sends the client a URL
8423
+ * elicitation. The v2 surface calls it after the setup response, since v2
8424
+ * asks for no elicitation before the `session/new` response.
8425
+ */
8426
+ authenticateMcpServers(sessionId, mcpServers) {
8427
+ startMcpAuthentication(this, sessionId, mcpServers);
8428
+ }
8339
8429
  /**
8340
8430
  * Sends the current state of each subagent that the last load of
8341
8431
  * `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it
@@ -8595,6 +8685,7 @@ export class ClaudeAcpAgent {
8595
8685
  agent: value === DEFAULT_AGENT_ID ? null : value,
8596
8686
  });
8597
8687
  session.currentAgent = value;
8688
+ session.agentSelected = true;
8598
8689
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
8599
8690
  }
8600
8691
  else if (configId === EFFORT_CONFIG_ID) {
@@ -8740,7 +8831,8 @@ export class ClaudeAcpAgent {
8740
8831
  });
8741
8832
  }
8742
8833
  async getOrCreateSession(params, resumedSession) {
8743
- // Reject a prompt the agent cannot apply before reusing a live session.
8834
+ // Reject a bad `cwd` or prompt before reusing or tearing down a live session.
8835
+ await this.validateCwd(params.cwd, params.additionalDirectories);
8744
8836
  clientSystemPrompt(params.systemPrompt);
8745
8837
  const existingSession = this.sessions[params.sessionId];
8746
8838
  // A recreated live session keeps its mode, not the mode of the transcript.
@@ -8788,8 +8880,16 @@ export class ClaudeAcpAgent {
8788
8880
  * directory before we create a session. Throws an `invalidParams` error with
8789
8881
  * an actionable message so clients (e.g. Zed) can surface it to the user
8790
8882
  * instead of failing later with an opaque SDK error.
8883
+ *
8884
+ * Each `additionalDirectories` entry must be absolute too. Only the lexical
8885
+ * rule is checked there: a missing extra root does not fail a session.
8791
8886
  */
8792
- async validateCwd(cwd) {
8887
+ async validateCwd(cwd, additionalDirectories) {
8888
+ for (const dir of additionalDirectories ?? []) {
8889
+ if (!path.isAbsolute(dir)) {
8890
+ throw RequestError.invalidParams({ additionalDirectories }, `\`additionalDirectories\` entries must be absolute paths, but received: ${dir}`);
8891
+ }
8892
+ }
8793
8893
  if (!path.isAbsolute(cwd)) {
8794
8894
  throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, but received: ${cwd}`);
8795
8895
  }
@@ -8817,7 +8917,7 @@ export class ClaudeAcpAgent {
8817
8917
  // this check a session is created against a missing directory and the
8818
8918
  // failure only surfaces later as a confusing "native binary failed to
8819
8919
  // launch" error from the SDK (see issue #749).
8820
- await this.validateCwd(params.cwd);
8920
+ await this.validateCwd(params.cwd, params.additionalDirectories);
8821
8921
  const clientPrompt = clientSystemPrompt(params.systemPrompt);
8822
8922
  // We want to create a new session id unless it is resume,
8823
8923
  // but not resume + forkSession.
@@ -9016,6 +9116,7 @@ export class ClaudeAcpAgent {
9016
9116
  ...providerEnv,
9017
9117
  // Opt-in to session state events like when the agent is idle
9018
9118
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
9119
+ ...STARTUP_FAILURE_RESULTS_ENV,
9019
9120
  };
9020
9121
  // Scopes the context-window cache to this query's backend (see
9021
9122
  // `contextWindowCache`). Derived from the same `env` object handed to the
@@ -9260,7 +9361,7 @@ export class ClaudeAcpAgent {
9260
9361
  error.message.includes("No conversation found with session ID"))) {
9261
9362
  throw RequestError.sessionNotFound(sessionId);
9262
9363
  }
9263
- throw error;
9364
+ throw startupFailureError(error) ?? error;
9264
9365
  }
9265
9366
  timing.phase("sdk-initialize");
9266
9367
  // Publish the identity BEFORE the guard can refuse this session. A
@@ -9427,6 +9528,9 @@ export class ClaudeAcpAgent {
9427
9528
  // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
9428
9529
  // the id itself is a trustworthy window signal.
9429
9530
  const seededWindow = immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
9531
+ if (creationOpts.replacingSession?.tornDown) {
9532
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
9533
+ }
9430
9534
  this.sessions[sessionId] = {
9431
9535
  query: q,
9432
9536
  input: input,
@@ -9537,27 +9641,8 @@ export class ClaudeAcpAgent {
9537
9641
  await Promise.all(activeTurns);
9538
9642
  }
9539
9643
  this.providerConfig = config;
9540
- for (const [sessionId, session] of sessions) {
9541
- if (this.sessions[sessionId] !== session || !session.creationParams) {
9542
- continue;
9543
- }
9544
- this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
9545
- this.closeQueryStream(session);
9546
- delete this.sessions[sessionId];
9547
- try {
9548
- await this.createSession(session.creationParams, {
9549
- resume: sessionId,
9550
- permissionMode: session.modes.currentModeId,
9551
- });
9552
- }
9553
- catch (error) {
9554
- // One session that cannot come back must not abort the switch. The
9555
- // `--hide-claude-auth` guard makes this a normal outcome of
9556
- // `providers/disable`: the override kept a subscription account
9557
- // usable, and creation refuses without it. The session is already
9558
- // gone, so tell the client why and go on to the next one.
9559
- this.reportSessionLostOnProviderUpdate(sessionId, session, error);
9560
- }
9644
+ for (const [sessionId] of sessions) {
9645
+ await this.serializeConfigurationChange(sessionId, () => this.restartSessionForProvider(sessionId));
9561
9646
  }
9562
9647
  });
9563
9648
  // Sessions and prompts await `providerUpdate` before they run. A rejected
@@ -9577,6 +9662,126 @@ export class ClaudeAcpAgent {
9577
9662
  }
9578
9663
  }
9579
9664
  }
9665
+ /** Starts a new query for the session under the new provider and restores its choices. */
9666
+ async restartSessionForProvider(sessionId) {
9667
+ // A sign-out respawn ahead of this change can have replaced the snapshot.
9668
+ const session = this.sessions[sessionId];
9669
+ if (!session?.creationParams) {
9670
+ return;
9671
+ }
9672
+ this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
9673
+ try {
9674
+ await this.replaceSessionQuery(sessionId, session, session.creationParams);
9675
+ }
9676
+ catch (error) {
9677
+ if (this.sessions[sessionId] === session)
9678
+ delete this.sessions[sessionId];
9679
+ if (session.tornDown)
9680
+ return;
9681
+ // One session that cannot come back must not abort the switch. The
9682
+ // `--hide-claude-auth` guard makes this a normal outcome of
9683
+ // `providers/disable`: the override kept a subscription account
9684
+ // usable, and creation refuses without it. The session is already
9685
+ // gone, so tell the client why and go on to the next one.
9686
+ this.reportSessionLostOnProviderUpdate(sessionId, session, error);
9687
+ }
9688
+ }
9689
+ /** Recreates the session's query and restores its choices and reported MCP servers, unless it was closed meanwhile. */
9690
+ async replaceSessionQuery(sessionId, session, creationParams) {
9691
+ this.closeQueryStream(session);
9692
+ // Keep the old record visible so closeSession and dispose can mark it torn down.
9693
+ // Without the creation-time effort: restoreSessionChoices restores the current pin instead.
9694
+ await this.recreateLiveSession(withoutEffort(creationParams), sessionId, {
9695
+ permissionMode: session.modes.currentModeId,
9696
+ replacingSession: session,
9697
+ });
9698
+ const recreated = this.sessions[sessionId];
9699
+ if (!recreated || recreated.tornDown)
9700
+ return;
9701
+ // Same MCP servers, so don't repeat their notices.
9702
+ if (session.reportedMcpServers) {
9703
+ recreated.reportedMcpServers = new Set(session.reportedMcpServers);
9704
+ }
9705
+ await this.restoreSessionChoices(sessionId, recreated, session);
9706
+ if (recreated.tornDown)
9707
+ return;
9708
+ // The recreated session works without this update, so a failed send is only logged.
9709
+ await this.client
9710
+ .sessionUpdate({
9711
+ sessionId,
9712
+ update: { sessionUpdate: "config_option_update", configOptions: recreated.configOptions },
9713
+ })
9714
+ .catch((error) => {
9715
+ this.logger.error(`Session ${sessionId}: could not publish the configuration restored after the query restart:`, error);
9716
+ });
9717
+ }
9718
+ /** Re-applies the choices of `previous` to the session that replaced it; a choice the new query rejects is logged. */
9719
+ async restoreSessionChoices(sessionId, session, previous) {
9720
+ const restore = async (choice, apply) => {
9721
+ if (session.tornDown)
9722
+ return;
9723
+ try {
9724
+ await apply();
9725
+ }
9726
+ catch (error) {
9727
+ if (!session.tornDown) {
9728
+ this.logger.error(`Could not restore ${choice} after the query restart:`, error);
9729
+ }
9730
+ }
9731
+ };
9732
+ const select = (configId, value) => restore(`${configId} "${value}"`, () => this.selectSessionConfigOption({ sessionId, configId, value }));
9733
+ // First: the model decides which modes, effort levels, Fast mode and ultracode exist.
9734
+ if (session.models.currentModelId !== previous.models.currentModelId) {
9735
+ await select(MODEL_CONFIG_ID, previous.models.currentModelId);
9736
+ }
9737
+ // After the model: creation falls back from "auto" to acceptEdits on a model without Auto.
9738
+ if (session.modes.currentModeId !== previous.modes.currentModeId) {
9739
+ await select(MODE_CONFIG_ID, previous.modes.currentModeId);
9740
+ if (session.modes.currentModeId === previous.modes.currentModeId) {
9741
+ session.autoModeFallbackWarningPending = false;
9742
+ }
9743
+ }
9744
+ if (session.modes.currentModeId === "plan") {
9745
+ session.prePlanMode = previous.prePlanMode;
9746
+ }
9747
+ // Compare intent, not the option: the option is hidden on a model without support.
9748
+ if (session.ultracodeEnabled !== previous.ultracodeEnabled) {
9749
+ await restore("ultracode", () => this.applyUltracode(session, previous.ultracodeEnabled));
9750
+ }
9751
+ if (session.fastModeEnabled !== previous.fastModeEnabled) {
9752
+ await restore("Fast mode", () => this.applyFastMode(session, previous.fastModeEnabled));
9753
+ }
9754
+ // After ultracode, since the effort flag carries it.
9755
+ if (previous.effortPinnedLevel !== undefined) {
9756
+ await select(EFFORT_CONFIG_ID, previous.effortPinnedLevel);
9757
+ }
9758
+ // Even when unchanged: Default over a hidden creation-time agent like "Explore" must clear it.
9759
+ if (previous.agentSelected) {
9760
+ if (previous.currentAgent === DEFAULT_AGENT_ID) {
9761
+ // Default is always valid, even when discovery returned no custom agents and hid the picker.
9762
+ await restore("Default agent", () => this.applyConfigOptionValue(sessionId, session, AGENT_CONFIG_ID, DEFAULT_AGENT_ID));
9763
+ }
9764
+ else {
9765
+ await select(AGENT_CONFIG_ID, previous.currentAgent);
9766
+ }
9767
+ }
9768
+ // Keep the original params and fingerprint paired for later load/resume calls.
9769
+ session.creationParams = previous.creationParams;
9770
+ session.sessionFingerprint = previous.sessionFingerprint;
9771
+ }
9772
+ serializeConfigurationChange(sessionId, change) {
9773
+ const result = (this.configurationChanges.get(sessionId) ?? Promise.resolve()).then(change);
9774
+ // Drops the entry once no newer change has queued behind this one.
9775
+ const tail = result
9776
+ .catch(() => { })
9777
+ .then(() => {
9778
+ if (this.configurationChanges.get(sessionId) === tail) {
9779
+ this.configurationChanges.delete(sessionId);
9780
+ }
9781
+ });
9782
+ this.configurationChanges.set(sessionId, tail);
9783
+ return result;
9784
+ }
9580
9785
  /** Tell a capable client when a non-auth error ends a session during a provider switch. */
9581
9786
  reportSessionLostOnProviderUpdate(sessionId, session, error) {
9582
9787
  this.logger.error(`Session ${sessionId}: could not be recreated for the provider update: ${error}`);
@@ -10289,11 +10494,20 @@ skillFiles) {
10289
10494
  };
10290
10495
  })
10291
10496
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
10497
+ // Command names must not contain whitespace and must be unique. A name
10498
+ // like "my command" is dropped, and of two commands named "review" only the
10499
+ // first is kept.
10500
+ const names = new Set();
10292
10501
  return [
10293
10502
  ...advertised,
10294
10503
  MCP_AVAILABLE_COMMAND,
10295
10504
  ...remoteControlCommands.filter((command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name)),
10296
- ];
10505
+ ].filter((command) => {
10506
+ if (/\s/.test(command.name) || names.has(command.name))
10507
+ return false;
10508
+ names.add(command.name);
10509
+ return true;
10510
+ });
10297
10511
  }
10298
10512
  function formatUriAsLink(uri) {
10299
10513
  try {
@@ -10313,15 +10527,26 @@ function formatUriAsLink(uri) {
10313
10527
  return uri;
10314
10528
  }
10315
10529
  }
10530
+ /** The prompt content block types that `promptToClaude` converts. The agent
10531
+ * advertises no `audio` prompt capability. */
10532
+ const SUPPORTED_PROMPT_CONTENT_TYPES = [
10533
+ "text",
10534
+ "image",
10535
+ "resource_link",
10536
+ "resource",
10537
+ ];
10316
10538
  export function promptToClaude(prompt) {
10317
10539
  const content = [];
10318
10540
  const context = [];
10541
+ const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
10319
10542
  for (const chunk of prompt.prompt) {
10320
10543
  switch (chunk.type) {
10321
10544
  case "text": {
10322
10545
  let text = chunk.text;
10323
- // change /mcp:server:command args -> /server:command (MCP) args
10324
- const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
10546
+ // A slash command is the first text block. Change
10547
+ // `/mcp:server:command args` to `/server:command (MCP) args`; the
10548
+ // arguments can span lines.
10549
+ const mcpMatch = chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
10325
10550
  if (mcpMatch) {
10326
10551
  const [, server, command, args] = mcpMatch;
10327
10552
  text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;