@agentclientprotocol/claude-agent-acp 0.99.1 → 0.99.2-preview.10

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 (47) hide show
  1. package/README.md +8 -0
  2. package/dist/acp-agent.d.ts +50 -7
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +315 -87
  5. package/dist/clear-context-coordinator.d.ts +4 -1
  6. package/dist/clear-context-coordinator.d.ts.map +1 -1
  7. package/dist/clear-context-coordinator.js +1 -1
  8. package/dist/diff.d.ts +1 -1
  9. package/dist/diff.js +5 -5
  10. package/dist/elicitation.d.ts +1 -2
  11. package/dist/elicitation.d.ts.map +1 -1
  12. package/dist/elicitation.js +34 -16
  13. package/dist/exit-plan.d.ts.map +1 -1
  14. package/dist/exit-plan.js +7 -1
  15. package/dist/fork-session.d.ts.map +1 -1
  16. package/dist/fork-session.js +23 -0
  17. package/dist/native-subagents.d.ts +49 -3
  18. package/dist/native-subagents.d.ts.map +1 -1
  19. package/dist/native-subagents.js +118 -4
  20. package/dist/session-index/service.d.ts +3 -1
  21. package/dist/session-index/service.d.ts.map +1 -1
  22. package/dist/session-index/service.js +5 -10
  23. package/dist/startup-failure.d.ts +16 -0
  24. package/dist/startup-failure.d.ts.map +1 -0
  25. package/dist/startup-failure.js +38 -0
  26. package/dist/subagent-history.d.ts +8 -1
  27. package/dist/subagent-history.d.ts.map +1 -1
  28. package/dist/subagent-history.js +40 -1
  29. package/dist/subagent-replay.d.ts +19 -3
  30. package/dist/subagent-replay.d.ts.map +1 -1
  31. package/dist/subagent-replay.js +46 -4
  32. package/dist/tool-calls/reporters/interaction.d.ts.map +1 -1
  33. package/dist/tool-calls/reporters/interaction.js +8 -3
  34. package/dist/v2/agent.d.ts.map +1 -1
  35. package/dist/v2/agent.js +40 -15
  36. package/dist/v2/initialize.d.ts +16 -1
  37. package/dist/v2/initialize.d.ts.map +1 -1
  38. package/dist/v2/initialize.js +26 -3
  39. package/dist/v2/permission.d.ts +5 -2
  40. package/dist/v2/permission.d.ts.map +1 -1
  41. package/dist/v2/permission.js +5 -2
  42. package/dist/v2/prompt.d.ts +2 -1
  43. package/dist/v2/prompt.d.ts.map +1 -1
  44. package/dist/v2/prompt.js +5 -1
  45. package/dist/v2/session.d.ts.map +1 -1
  46. package/dist/v2/session.js +21 -9
  47. 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;
@@ -1110,7 +1121,10 @@ async function authenticateMcpServer(host, sessionId, query, serverName) {
1110
1121
  completed.then((authenticated) => ({ type: "completed", authenticated })),
1111
1122
  elicitation.then((response) => ({ type: "elicitation", response })),
1112
1123
  ]);
1113
- if (first.type === "elicitation" && !CreateElicitationResponse.isAccept(first.response)) {
1124
+ // A url-mode accept carries no content to check. The guard
1125
+ // `CreateElicitationResponse.isAccept` would also reject an accept whose
1126
+ // `content` holds a v2-only value, such as an object.
1127
+ if (first.type === "elicitation" && first.response.action !== "accept") {
1114
1128
  return;
1115
1129
  }
1116
1130
  if (first.type === "elicitation") {
@@ -1159,6 +1173,8 @@ export class ClaudeAcpAgent {
1159
1173
  providerConfig;
1160
1174
  /** Serializes provider changes while every open query is recreated between turns. */
1161
1175
  providerUpdate = null;
1176
+ /** Per session: queue that runs mode/config changes and query restarts one at a time. */
1177
+ configurationChanges = new Map();
1162
1178
  exitPlan;
1163
1179
  /** Last auth identity reported to the client, connection-scoped like
1164
1180
  * `authenticate`/`logout`. Undefined means "not determined yet". */
@@ -1207,6 +1223,12 @@ export class ClaudeAcpAgent {
1207
1223
  replayedChildModels = new Map();
1208
1224
  /** See the `foreground` constructor option. */
1209
1225
  foregroundEvents;
1226
+ /** Counts the prompts `startTurn()` has received, across sessions. */
1227
+ promptsReceived = 0;
1228
+ /** Per session, the `promptsReceived` count when its last cancel started.
1229
+ * A prompt received before that cancel that `startTurn()` had not queued
1230
+ * yet ends as cancelled instead of being queued. */
1231
+ cancelledThrough = new Map();
1210
1232
  /**
1211
1233
  * The foreground work of each session while it runs, by the session id that
1212
1234
  * the client knows, on every connection: only the `foreground` option
@@ -1490,7 +1512,7 @@ export class ClaudeAcpAgent {
1490
1512
  * before the first update. A failed CLI start stops the replay.
1491
1513
  */
1492
1514
  async createSessionWhileReplaying(params, timing) {
1493
- await this.validateCwd(params.cwd);
1515
+ await this.validateCwd(params.cwd, params.additionalDirectories);
1494
1516
  clientSystemPrompt(params.systemPrompt);
1495
1517
  const pending = {
1496
1518
  state: {
@@ -1548,7 +1570,8 @@ export class ClaudeAcpAgent {
1548
1570
  async unstable_forkSession(params) {
1549
1571
  while (this.providerUpdate)
1550
1572
  await this.providerUpdate;
1551
- // Reject a prompt the agent cannot apply before copying the transcript.
1573
+ // Reject a bad `cwd` or prompt before copying the transcript.
1574
+ await this.validateCwd(params.cwd, params.additionalDirectories);
1552
1575
  clientSystemPrompt(params.systemPrompt);
1553
1576
  const { sessionId } = await forkSession(params, {
1554
1577
  liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
@@ -1607,13 +1630,19 @@ export class ClaudeAcpAgent {
1607
1630
  * reads the start and the end only of the transcripts of the page.
1608
1631
  */
1609
1632
  async listSessions(params) {
1633
+ if (params.cwd != null && !path.isAbsolute(params.cwd)) {
1634
+ throw RequestError.invalidParams({ cwd: params.cwd }, `\`cwd\` must be an absolute path, but received: ${params.cwd}`);
1635
+ }
1610
1636
  const indexed = this.sessionIndex.list(params);
1611
1637
  if (indexed)
1612
1638
  return indexed;
1613
1639
  const offset = sessionListOffset(params.cursor);
1614
1640
  // One more session than the page tells whether a next page exists.
1641
+ // `includeWorktrees: false`: a `cwd` filter matches sessions of exactly
1642
+ // that directory, not of the other git worktrees of its repository.
1615
1643
  const sdkSessions = await listSessions({
1616
1644
  dir: params.cwd ?? undefined,
1645
+ includeWorktrees: false,
1617
1646
  limit: SESSION_LIST_PAGE_SIZE + 1,
1618
1647
  offset,
1619
1648
  });
@@ -1689,7 +1718,7 @@ export class ClaudeAcpAgent {
1689
1718
  this.setAuthStatus(gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayAuthRequest)?.baseUrl));
1690
1719
  return;
1691
1720
  }
1692
- throw new Error("Method not implemented.");
1721
+ throw RequestError.invalidParams({ methodId: _params.methodId }, `Unsupported authentication method: ${_params.methodId}`);
1693
1722
  }
1694
1723
  /**
1695
1724
  * Stores `next` and pushes it to the client.
@@ -2131,8 +2160,18 @@ export class ClaudeAcpAgent {
2131
2160
  /**
2132
2161
  * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
2133
2162
  * outcome of a turn is field for field a v1 prompt response.
2163
+ *
2164
+ * A content block that the agent does not advertise, such as `audio`, fails
2165
+ * the request with invalid params (-32602) before anything is queued:
2166
+ * `promptToClaude` would drop it. v2 prompts reach `startTurn` through
2167
+ * `v1PromptRequest`, which rejects these blocks itself.
2134
2168
  */
2135
2169
  async prompt(params) {
2170
+ for (const block of params.prompt) {
2171
+ if (!SUPPORTED_PROMPT_CONTENT_TYPES.includes(block.type)) {
2172
+ throw RequestError.invalidParams({ type: block.type }, `Content blocks of type ${block.type} are not supported`);
2173
+ }
2174
+ }
2136
2175
  let events;
2137
2176
  const outcome = new Promise((resolve, reject) => {
2138
2177
  events = { inserted() { }, ended: resolve, failed: reject };
@@ -2147,6 +2186,7 @@ export class ClaudeAcpAgent {
2147
2186
  * before this resolves.
2148
2187
  */
2149
2188
  async startTurn(params, events) {
2189
+ const received = ++this.promptsReceived;
2150
2190
  while (this.providerUpdate)
2151
2191
  await this.providerUpdate;
2152
2192
  let session = this.sessions[params.sessionId];
@@ -2164,8 +2204,16 @@ export class ClaudeAcpAgent {
2164
2204
  this.useAuthStatusSettings(session.settingsManager, session.effortSettingsOverride, session.creationParams?._meta?.claudeCode?.options?.env);
2165
2205
  void this.probeCliAuthStatus();
2166
2206
  const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
2167
- if (signOutRespawn)
2168
- session = await signOutRespawn;
2207
+ if (signOutRespawn) {
2208
+ await signOutRespawn;
2209
+ // A provider update can queue its replacement behind the sign-out respawn.
2210
+ // Wait for that replacement too before a prompt starts on the old route.
2211
+ while (this.providerUpdate)
2212
+ await this.providerUpdate;
2213
+ session = this.sessions[params.sessionId];
2214
+ if (!session)
2215
+ throw RequestError.sessionNotFound(params.sessionId);
2216
+ }
2169
2217
  // The SDK query stream already terminated (see `queryClosed`); its iterator
2170
2218
  // can't be revived, so enqueueing here would hang on a deferred that never
2171
2219
  // settles. Fail clearly and let the client start a fresh session.
@@ -2252,7 +2300,9 @@ export class ClaudeAcpAgent {
2252
2300
  // its consumer is gone, so the prompt would never be inserted. End it as
2253
2301
  // the close ends a queued turn: `cancelled` on v1, -32800 on v2. A stream
2254
2302
  // that ended meanwhile fails the prompt as the check above does.
2255
- if (session.tornDown) {
2303
+ // A `session/cancel` that arrived after this prompt, during the awaits
2304
+ // above, ends it the same way.
2305
+ if (session.tornDown || received <= (this.cancelledThrough.get(params.sessionId) ?? 0)) {
2256
2306
  session.fileChangeReporter?.finish(fileChangeReport, "cancelled");
2257
2307
  events.ended({ stopReason: "cancelled" });
2258
2308
  return;
@@ -2266,6 +2316,7 @@ export class ClaudeAcpAgent {
2266
2316
  // the consumer is running.
2267
2317
  let completeTurn;
2268
2318
  const turn = {
2319
+ received,
2269
2320
  promptUuid,
2270
2321
  events,
2271
2322
  isLocalOnlyCommand,
@@ -2457,36 +2508,29 @@ export class ClaudeAcpAgent {
2457
2508
  });
2458
2509
  }
2459
2510
  }
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);
2511
+ /** Queued per session, so a choice sent during the respawn lands on the new query. */
2512
+ recreateSignedOutQuery(sessionId, session) {
2513
+ return this.serializeConfigurationChange(sessionId, async () => {
2514
+ const creationParams = session.creationParams;
2515
+ if (!creationParams) {
2516
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
2480
2517
  }
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;
2518
+ // Already replaced by a provider restart, or closed.
2519
+ if (this.sessions[sessionId] !== session)
2520
+ return;
2521
+ this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
2522
+ try {
2523
+ // The signed-out turn may be the first, so the conversation may not be saved yet.
2524
+ await this.replaceSessionQuery(sessionId, session, creationParams);
2487
2525
  }
2488
- throw error;
2489
- }
2526
+ catch (error) {
2527
+ // Keep the husk so the client can sign in and retry; a closed session stays closed.
2528
+ if (!session.tornDown && !this.sessions[sessionId]) {
2529
+ this.sessions[sessionId] = session;
2530
+ }
2531
+ throw error;
2532
+ }
2533
+ });
2490
2534
  }
2491
2535
  /**
2492
2536
  * Starts a new query for the session `sessionId`, which ran on this
@@ -2510,6 +2554,7 @@ export class ClaudeAcpAgent {
2510
2554
  return await this.createSession(params, {
2511
2555
  reuseSessionId: sessionId,
2512
2556
  permissionMode: options.permissionMode,
2557
+ replacingSession: options.replacingSession,
2513
2558
  });
2514
2559
  }
2515
2560
  }
@@ -6442,6 +6487,12 @@ export class ClaudeAcpAgent {
6442
6487
  return;
6443
6488
  }
6444
6489
  session.cancelled = true;
6490
+ // Only prompts received before this cancel are cancelled. A prompt that
6491
+ // arrives while the awaits below run is queued and runs after the cancel
6492
+ // (ACP v2: a `session/prompt` received after `session/cancel` is not
6493
+ // cancelled by it).
6494
+ const cancelledThrough = this.promptsReceived;
6495
+ this.cancelledThrough.set(params.sessionId, cancelledThrough);
6445
6496
  // The interrupt below makes Claude Code withdraw the requests that are open
6446
6497
  // now (see `withPendingUserInput`).
6447
6498
  for (const input of session.openUserInputs ?? [])
@@ -6450,7 +6501,7 @@ export class ClaudeAcpAgent {
6450
6501
  // the meantime is already judged against these prompts.
6451
6502
  session.cancelledPrompts ??= new Set();
6452
6503
  for (const turn of session.turnQueue ?? []) {
6453
- if (turn.settled)
6504
+ if (turn.settled || turn.received > cancelledThrough)
6454
6505
  continue;
6455
6506
  session.cancelledPrompts.add(turn.promptUuid);
6456
6507
  for (const uuid of turn.steeredUuids ?? [])
@@ -6525,7 +6576,7 @@ export class ClaudeAcpAgent {
6525
6576
  const orphanedTurns = [];
6526
6577
  if (session.turnQueue) {
6527
6578
  for (const turn of session.turnQueue) {
6528
- if (turn !== session.activeTurn && !turn.settled) {
6579
+ if (turn !== session.activeTurn && !turn.settled && turn.received <= cancelledThrough) {
6529
6580
  session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
6530
6581
  turn.settled = true;
6531
6582
  // Deliberately no `usage`: a queued turn never ran, so the session
@@ -6579,7 +6630,7 @@ export class ClaudeAcpAgent {
6579
6630
  }
6580
6631
  this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
6581
6632
  }
6582
- session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
6633
+ session.turnQueue = session.turnQueue.filter((turn) => !turn.settled);
6583
6634
  }
6584
6635
  const diagnosticTurns = orphanedTurns.filter((turn) => {
6585
6636
  if (turn.commandFinished === "completed" ||
@@ -6702,8 +6753,8 @@ export class ClaudeAcpAgent {
6702
6753
  }
6703
6754
  // Before the interrupt: once it stops the running turn, Claude Code starts
6704
6755
  // 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.
6756
+ // A steer that arrived while this cancel awaited is abandoned too, after
6757
+ // the record above was taken.
6707
6758
  for (const uuid of abandoned)
6708
6759
  (session.cancelledPrompts ??= new Set()).add(uuid);
6709
6760
  this.dropQueuedMessages(params.sessionId, session.query, abandoned);
@@ -6853,6 +6904,7 @@ export class ClaudeAcpAgent {
6853
6904
  session.nativeSubagentRuntime?.clear();
6854
6905
  session.asyncTaskRuntime?.clear();
6855
6906
  this.replayedChildModels.delete(sessionId);
6907
+ this.cancelledThrough.delete(sessionId);
6856
6908
  delete this.sessions[sessionId];
6857
6909
  this.sessionIndex.onTeardown(sessionId);
6858
6910
  const ended = await raceTimeoutAndAbort(turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, new AbortController().signal);
@@ -6895,10 +6947,13 @@ export class ClaudeAcpAgent {
6895
6947
  async deleteSession(params) {
6896
6948
  return this.sessionIndex.deleteSession(params);
6897
6949
  }
6898
- async setSessionMode(params) {
6899
- return this.sessionModes.setSessionMode(params);
6950
+ setSessionMode(params) {
6951
+ return this.serializeConfigurationChange(params.sessionId, () => this.sessionModes.setSessionMode(params));
6952
+ }
6953
+ setSessionConfigOption(params) {
6954
+ return this.serializeConfigurationChange(params.sessionId, () => this.selectSessionConfigOption(params));
6900
6955
  }
6901
- async setSessionConfigOption(params) {
6956
+ async selectSessionConfigOption(params) {
6902
6957
  const session = this.sessions[params.sessionId];
6903
6958
  if (!session) {
6904
6959
  throw RequestError.sessionNotFound(params.sessionId);
@@ -6914,6 +6969,13 @@ export class ClaudeAcpAgent {
6914
6969
  if (!option) {
6915
6970
  throw RequestError.invalidParams(undefined, `Unknown config option: ${params.configId}`);
6916
6971
  }
6972
+ // The value must match the type the option was advertised with: a
6973
+ // `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a
6974
+ // `type: "select"` option takes a string.
6975
+ const expectedType = option.type === "boolean" ? "boolean" : "string";
6976
+ if (typeof params.value !== expectedType) {
6977
+ throw RequestError.invalidParams({ configId: params.configId, value: params.value }, `Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`);
6978
+ }
6917
6979
  // Fast mode and ultracode carry a boolean value (for Clients that opted
6918
6980
  // into boolean config options) or the "on"/"off" select fallback, so they
6919
6981
  // bypass the
@@ -7131,7 +7193,7 @@ export class ClaudeAcpAgent {
7131
7193
  ? await subagentHistory(sessionId)
7132
7194
  : { agents: new Map() };
7133
7195
  const rfdReplay = form === "rfd"
7134
- ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.subagentModels(sessionId), !pending)
7196
+ ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.subagentModels(sessionId), !pending, session?.cwd)
7135
7197
  : undefined;
7136
7198
  rfdReplay?.scan(messages, sessionId);
7137
7199
  // History persists no task_started frame, so a background task gets its
@@ -7679,9 +7741,11 @@ export class ClaudeAcpAgent {
7679
7741
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
7680
7742
  * while the client's prompt is still open the signal aborts, the SDK sends
7681
7743
  * `$/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) {
7744
+ * ignores it. A request rejection and a local abort surface the same
7745
+ * "Tool use aborted" that the callers expect. `canUseTool` decides what a
7746
+ * `cancelled` outcome means. */
7747
+ async requestPermissionFromClient(params, toolName, toolSignal, parentToolUseId, ownerSessionId, agentID, toolInput, previewContent) {
7748
+ const signal = this.untilSessionClosed(ownerSessionId, toolSignal);
7685
7749
  if (signal.aborted)
7686
7750
  throw new Error("Tool use aborted");
7687
7751
  // The SDK may invoke `canUseTool` (and therefore this permission request)
@@ -7719,6 +7783,15 @@ export class ClaudeAcpAgent {
7719
7783
  elicitFromClient(request, signal) {
7720
7784
  return raceWithAbort(this.client.createElicitation(request, signal), signal);
7721
7785
  }
7786
+ /** `signal`, also aborted when `teardownSession` aborts the
7787
+ * `abortController` of session `sessionId`. Claude Code aborts a callback's
7788
+ * own signal when its query closes, but not necessarily before
7789
+ * `session/close` responds, and v2 requires every pending elicitation of
7790
+ * the session to be cancelled with `$/cancel_request` before that response. */
7791
+ untilSessionClosed(sessionId, signal) {
7792
+ const sessionSignal = this.sessions[sessionId]?.abortController?.signal;
7793
+ return sessionSignal ? AbortSignal.any([signal, sessionSignal]) : signal;
7794
+ }
7722
7795
  /** Settle the tool call `ensureToolCallEmitted` emitted for a
7723
7796
  * SandboxNetworkAccess request once the request is answered. The callback
7724
7797
  * asks for permission and runs nothing, so the SDK never sends a
@@ -7852,6 +7925,11 @@ export class ClaudeAcpAgent {
7852
7925
  const session = this.sessions[sessionId];
7853
7926
  if (!session || !input.agent_id)
7854
7927
  return { continue: true };
7928
+ // A hook's `cwd` is the child's working directory, e.g. its worktree.
7929
+ // Reported before the hook returns, so before the work that follows it.
7930
+ await session.nativeSubagentRuntime
7931
+ ?.reportCwd(input.agent_id, input.cwd)
7932
+ .catch((error) => this.logger.error(`Session ${sessionId}: failed to report subagent cwd: ${error}`));
7855
7933
  const options = [];
7856
7934
  if (input.permission_mode) {
7857
7935
  const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode);
@@ -8077,9 +8155,13 @@ export class ClaudeAcpAgent {
8077
8155
  // cancel does not stop. Answer such a request with a denial so the
8078
8156
  // subagent reads "the user refused" and goes on. Throwing instead makes
8079
8157
  // Claude Code deny it as "Tool permission request failed".
8158
+ // On ACP v2, `cancelled` also answers a prompt that the user dismissed
8159
+ // or that the client declined by policy. Without a `session/cancel`
8160
+ // (`session.cancelled` unset), that is the user refusing the tool, so it
8161
+ // gets the same denial and the turn goes on.
8080
8162
  if (response.outcome?.outcome === "cancelled" &&
8081
8163
  this.sessions[sessionId] === session &&
8082
- !blocksForeground(session, agentID)) {
8164
+ (!blocksForeground(session, agentID) || (this.v2 && !session.cancelled))) {
8083
8165
  return {
8084
8166
  behavior: "deny",
8085
8167
  message: "User refused permission to run tool",
@@ -8143,7 +8225,8 @@ export class ClaudeAcpAgent {
8143
8225
  * an error, is a `cancel`.
8144
8226
  */
8145
8227
  handleMcpElicitation(sessionId, support) {
8146
- return async (request, { signal }) => {
8228
+ return async (request, { signal: callbackSignal }) => {
8229
+ const signal = this.untilSessionClosed(sessionId, callbackSignal);
8147
8230
  const isUrl = request.mode === "url";
8148
8231
  if ((isUrl && !support.url) || (!isUrl && !support.form)) {
8149
8232
  return { action: "cancel" };
@@ -8180,7 +8263,8 @@ export class ClaudeAcpAgent {
8180
8263
  * The elicitation goes to `requestSessionId`, a subagent's child session for
8181
8264
  * a question it asks, and blocks the turn of `sessionId`.
8182
8265
  */
8183
- async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal, agentID) {
8266
+ async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, toolSignal, agentID) {
8267
+ const signal = this.untilSessionClosed(sessionId, toolSignal);
8184
8268
  const questions = extractAskUserQuestions(toolInput);
8185
8269
  if (!questions) {
8186
8270
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
@@ -8219,7 +8303,8 @@ export class ClaudeAcpAgent {
8219
8303
  * refusal-fallback consent prompt, rendered as an ACP form elicitation.
8220
8304
  */
8221
8305
  handleUserDialog(sessionId) {
8222
- return async (request, { signal }) => {
8306
+ return async (request, { signal: callbackSignal }) => {
8307
+ const signal = this.untilSessionClosed(sessionId, callbackSignal);
8223
8308
  if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
8224
8309
  return { behavior: "cancelled" };
8225
8310
  }
@@ -8323,19 +8408,27 @@ export class ClaudeAcpAgent {
8323
8408
  /**
8324
8409
  * The work that follows a session setup response: the session's commands,
8325
8410
  * 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.
8411
+ * authentication. On v2 the setup handlers of `src/v2/agent.ts` do this
8412
+ * work once they have sent their response, so it does nothing here.
8329
8413
  */
8330
8414
  afterSetupResponse(sessionId, mcpServers) {
8415
+ if (this.v2)
8416
+ return;
8331
8417
  setTimeout(() => {
8332
- if (!this.v2) {
8333
- this.sendAvailableCommandsUpdate(sessionId);
8334
- void this.publishCurrentSubagentStates(sessionId);
8335
- }
8418
+ this.sendAvailableCommandsUpdate(sessionId);
8419
+ void this.publishCurrentSubagentStates(sessionId);
8336
8420
  startMcpAuthentication(this, sessionId, mcpServers);
8337
8421
  }, 0);
8338
8422
  }
8423
+ /**
8424
+ * Starts OAuth in the background for the servers of `mcpServers` that
8425
+ * Claude Code reports as needing it, which sends the client a URL
8426
+ * elicitation. The v2 surface calls it after the setup response, since v2
8427
+ * asks for no elicitation before the `session/new` response.
8428
+ */
8429
+ authenticateMcpServers(sessionId, mcpServers) {
8430
+ startMcpAuthentication(this, sessionId, mcpServers);
8431
+ }
8339
8432
  /**
8340
8433
  * Sends the current state of each subagent that the last load of
8341
8434
  * `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it
@@ -8595,6 +8688,7 @@ export class ClaudeAcpAgent {
8595
8688
  agent: value === DEFAULT_AGENT_ID ? null : value,
8596
8689
  });
8597
8690
  session.currentAgent = value;
8691
+ session.agentSelected = true;
8598
8692
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
8599
8693
  }
8600
8694
  else if (configId === EFFORT_CONFIG_ID) {
@@ -8740,7 +8834,8 @@ export class ClaudeAcpAgent {
8740
8834
  });
8741
8835
  }
8742
8836
  async getOrCreateSession(params, resumedSession) {
8743
- // Reject a prompt the agent cannot apply before reusing a live session.
8837
+ // Reject a bad `cwd` or prompt before reusing or tearing down a live session.
8838
+ await this.validateCwd(params.cwd, params.additionalDirectories);
8744
8839
  clientSystemPrompt(params.systemPrompt);
8745
8840
  const existingSession = this.sessions[params.sessionId];
8746
8841
  // A recreated live session keeps its mode, not the mode of the transcript.
@@ -8788,8 +8883,16 @@ export class ClaudeAcpAgent {
8788
8883
  * directory before we create a session. Throws an `invalidParams` error with
8789
8884
  * an actionable message so clients (e.g. Zed) can surface it to the user
8790
8885
  * instead of failing later with an opaque SDK error.
8886
+ *
8887
+ * Each `additionalDirectories` entry must be absolute too. Only the lexical
8888
+ * rule is checked there: a missing extra root does not fail a session.
8791
8889
  */
8792
- async validateCwd(cwd) {
8890
+ async validateCwd(cwd, additionalDirectories) {
8891
+ for (const dir of additionalDirectories ?? []) {
8892
+ if (!path.isAbsolute(dir)) {
8893
+ throw RequestError.invalidParams({ additionalDirectories }, `\`additionalDirectories\` entries must be absolute paths, but received: ${dir}`);
8894
+ }
8895
+ }
8793
8896
  if (!path.isAbsolute(cwd)) {
8794
8897
  throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, but received: ${cwd}`);
8795
8898
  }
@@ -8817,7 +8920,7 @@ export class ClaudeAcpAgent {
8817
8920
  // this check a session is created against a missing directory and the
8818
8921
  // failure only surfaces later as a confusing "native binary failed to
8819
8922
  // launch" error from the SDK (see issue #749).
8820
- await this.validateCwd(params.cwd);
8923
+ await this.validateCwd(params.cwd, params.additionalDirectories);
8821
8924
  const clientPrompt = clientSystemPrompt(params.systemPrompt);
8822
8925
  // We want to create a new session id unless it is resume,
8823
8926
  // but not resume + forkSession.
@@ -9016,6 +9119,7 @@ export class ClaudeAcpAgent {
9016
9119
  ...providerEnv,
9017
9120
  // Opt-in to session state events like when the agent is idle
9018
9121
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
9122
+ ...STARTUP_FAILURE_RESULTS_ENV,
9019
9123
  };
9020
9124
  // Scopes the context-window cache to this query's backend (see
9021
9125
  // `contextWindowCache`). Derived from the same `env` object handed to the
@@ -9260,7 +9364,7 @@ export class ClaudeAcpAgent {
9260
9364
  error.message.includes("No conversation found with session ID"))) {
9261
9365
  throw RequestError.sessionNotFound(sessionId);
9262
9366
  }
9263
- throw error;
9367
+ throw startupFailureError(error) ?? error;
9264
9368
  }
9265
9369
  timing.phase("sdk-initialize");
9266
9370
  // Publish the identity BEFORE the guard can refuse this session. A
@@ -9427,6 +9531,9 @@ export class ClaudeAcpAgent {
9427
9531
  // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
9428
9532
  // the id itself is a trustworthy window signal.
9429
9533
  const seededWindow = immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
9534
+ if (creationOpts.replacingSession?.tornDown) {
9535
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
9536
+ }
9430
9537
  this.sessions[sessionId] = {
9431
9538
  query: q,
9432
9539
  input: input,
@@ -9537,27 +9644,8 @@ export class ClaudeAcpAgent {
9537
9644
  await Promise.all(activeTurns);
9538
9645
  }
9539
9646
  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
- }
9647
+ for (const [sessionId] of sessions) {
9648
+ await this.serializeConfigurationChange(sessionId, () => this.restartSessionForProvider(sessionId));
9561
9649
  }
9562
9650
  });
9563
9651
  // Sessions and prompts await `providerUpdate` before they run. A rejected
@@ -9577,6 +9665,126 @@ export class ClaudeAcpAgent {
9577
9665
  }
9578
9666
  }
9579
9667
  }
9668
+ /** Starts a new query for the session under the new provider and restores its choices. */
9669
+ async restartSessionForProvider(sessionId) {
9670
+ // A sign-out respawn ahead of this change can have replaced the snapshot.
9671
+ const session = this.sessions[sessionId];
9672
+ if (!session?.creationParams) {
9673
+ return;
9674
+ }
9675
+ this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
9676
+ try {
9677
+ await this.replaceSessionQuery(sessionId, session, session.creationParams);
9678
+ }
9679
+ catch (error) {
9680
+ if (this.sessions[sessionId] === session)
9681
+ delete this.sessions[sessionId];
9682
+ if (session.tornDown)
9683
+ return;
9684
+ // One session that cannot come back must not abort the switch. The
9685
+ // `--hide-claude-auth` guard makes this a normal outcome of
9686
+ // `providers/disable`: the override kept a subscription account
9687
+ // usable, and creation refuses without it. The session is already
9688
+ // gone, so tell the client why and go on to the next one.
9689
+ this.reportSessionLostOnProviderUpdate(sessionId, session, error);
9690
+ }
9691
+ }
9692
+ /** Recreates the session's query and restores its choices and reported MCP servers, unless it was closed meanwhile. */
9693
+ async replaceSessionQuery(sessionId, session, creationParams) {
9694
+ this.closeQueryStream(session);
9695
+ // Keep the old record visible so closeSession and dispose can mark it torn down.
9696
+ // Without the creation-time effort: restoreSessionChoices restores the current pin instead.
9697
+ await this.recreateLiveSession(withoutEffort(creationParams), sessionId, {
9698
+ permissionMode: session.modes.currentModeId,
9699
+ replacingSession: session,
9700
+ });
9701
+ const recreated = this.sessions[sessionId];
9702
+ if (!recreated || recreated.tornDown)
9703
+ return;
9704
+ // Same MCP servers, so don't repeat their notices.
9705
+ if (session.reportedMcpServers) {
9706
+ recreated.reportedMcpServers = new Set(session.reportedMcpServers);
9707
+ }
9708
+ await this.restoreSessionChoices(sessionId, recreated, session);
9709
+ if (recreated.tornDown)
9710
+ return;
9711
+ // The recreated session works without this update, so a failed send is only logged.
9712
+ await this.client
9713
+ .sessionUpdate({
9714
+ sessionId,
9715
+ update: { sessionUpdate: "config_option_update", configOptions: recreated.configOptions },
9716
+ })
9717
+ .catch((error) => {
9718
+ this.logger.error(`Session ${sessionId}: could not publish the configuration restored after the query restart:`, error);
9719
+ });
9720
+ }
9721
+ /** Re-applies the choices of `previous` to the session that replaced it; a choice the new query rejects is logged. */
9722
+ async restoreSessionChoices(sessionId, session, previous) {
9723
+ const restore = async (choice, apply) => {
9724
+ if (session.tornDown)
9725
+ return;
9726
+ try {
9727
+ await apply();
9728
+ }
9729
+ catch (error) {
9730
+ if (!session.tornDown) {
9731
+ this.logger.error(`Could not restore ${choice} after the query restart:`, error);
9732
+ }
9733
+ }
9734
+ };
9735
+ const select = (configId, value) => restore(`${configId} "${value}"`, () => this.selectSessionConfigOption({ sessionId, configId, value }));
9736
+ // First: the model decides which modes, effort levels, Fast mode and ultracode exist.
9737
+ if (session.models.currentModelId !== previous.models.currentModelId) {
9738
+ await select(MODEL_CONFIG_ID, previous.models.currentModelId);
9739
+ }
9740
+ // After the model: creation falls back from "auto" to acceptEdits on a model without Auto.
9741
+ if (session.modes.currentModeId !== previous.modes.currentModeId) {
9742
+ await select(MODE_CONFIG_ID, previous.modes.currentModeId);
9743
+ if (session.modes.currentModeId === previous.modes.currentModeId) {
9744
+ session.autoModeFallbackWarningPending = false;
9745
+ }
9746
+ }
9747
+ if (session.modes.currentModeId === "plan") {
9748
+ session.prePlanMode = previous.prePlanMode;
9749
+ }
9750
+ // Compare intent, not the option: the option is hidden on a model without support.
9751
+ if (session.ultracodeEnabled !== previous.ultracodeEnabled) {
9752
+ await restore("ultracode", () => this.applyUltracode(session, previous.ultracodeEnabled));
9753
+ }
9754
+ if (session.fastModeEnabled !== previous.fastModeEnabled) {
9755
+ await restore("Fast mode", () => this.applyFastMode(session, previous.fastModeEnabled));
9756
+ }
9757
+ // After ultracode, since the effort flag carries it.
9758
+ if (previous.effortPinnedLevel !== undefined) {
9759
+ await select(EFFORT_CONFIG_ID, previous.effortPinnedLevel);
9760
+ }
9761
+ // Even when unchanged: Default over a hidden creation-time agent like "Explore" must clear it.
9762
+ if (previous.agentSelected) {
9763
+ if (previous.currentAgent === DEFAULT_AGENT_ID) {
9764
+ // Default is always valid, even when discovery returned no custom agents and hid the picker.
9765
+ await restore("Default agent", () => this.applyConfigOptionValue(sessionId, session, AGENT_CONFIG_ID, DEFAULT_AGENT_ID));
9766
+ }
9767
+ else {
9768
+ await select(AGENT_CONFIG_ID, previous.currentAgent);
9769
+ }
9770
+ }
9771
+ // Keep the original params and fingerprint paired for later load/resume calls.
9772
+ session.creationParams = previous.creationParams;
9773
+ session.sessionFingerprint = previous.sessionFingerprint;
9774
+ }
9775
+ serializeConfigurationChange(sessionId, change) {
9776
+ const result = (this.configurationChanges.get(sessionId) ?? Promise.resolve()).then(change);
9777
+ // Drops the entry once no newer change has queued behind this one.
9778
+ const tail = result
9779
+ .catch(() => { })
9780
+ .then(() => {
9781
+ if (this.configurationChanges.get(sessionId) === tail) {
9782
+ this.configurationChanges.delete(sessionId);
9783
+ }
9784
+ });
9785
+ this.configurationChanges.set(sessionId, tail);
9786
+ return result;
9787
+ }
9580
9788
  /** Tell a capable client when a non-auth error ends a session during a provider switch. */
9581
9789
  reportSessionLostOnProviderUpdate(sessionId, session, error) {
9582
9790
  this.logger.error(`Session ${sessionId}: could not be recreated for the provider update: ${error}`);
@@ -10289,11 +10497,20 @@ skillFiles) {
10289
10497
  };
10290
10498
  })
10291
10499
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
10500
+ // Command names must not contain whitespace and must be unique. A name
10501
+ // like "my command" is dropped, and of two commands named "review" only the
10502
+ // first is kept.
10503
+ const names = new Set();
10292
10504
  return [
10293
10505
  ...advertised,
10294
10506
  MCP_AVAILABLE_COMMAND,
10295
10507
  ...remoteControlCommands.filter((command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name)),
10296
- ];
10508
+ ].filter((command) => {
10509
+ if (/\s/.test(command.name) || names.has(command.name))
10510
+ return false;
10511
+ names.add(command.name);
10512
+ return true;
10513
+ });
10297
10514
  }
10298
10515
  function formatUriAsLink(uri) {
10299
10516
  try {
@@ -10313,15 +10530,26 @@ function formatUriAsLink(uri) {
10313
10530
  return uri;
10314
10531
  }
10315
10532
  }
10533
+ /** The prompt content block types that `promptToClaude` converts. The agent
10534
+ * advertises no `audio` prompt capability. */
10535
+ const SUPPORTED_PROMPT_CONTENT_TYPES = [
10536
+ "text",
10537
+ "image",
10538
+ "resource_link",
10539
+ "resource",
10540
+ ];
10316
10541
  export function promptToClaude(prompt) {
10317
10542
  const content = [];
10318
10543
  const context = [];
10544
+ const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
10319
10545
  for (const chunk of prompt.prompt) {
10320
10546
  switch (chunk.type) {
10321
10547
  case "text": {
10322
10548
  let text = chunk.text;
10323
- // change /mcp:server:command args -> /server:command (MCP) args
10324
- const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
10549
+ // A slash command is the first text block. Change
10550
+ // `/mcp:server:command args` to `/server:command (MCP) args`; the
10551
+ // arguments can span lines.
10552
+ const mcpMatch = chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
10325
10553
  if (mcpMatch) {
10326
10554
  const [, server, command, args] = mcpMatch;
10327
10555
  text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;