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

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.
package/dist/acp-agent.js CHANGED
@@ -9,6 +9,7 @@ import { childModelOption, isNativeSubagentControlTool, isNativeSubagentControlU
9
9
  import { AIR_ASYNC_TASKS_CAPABILITY, AIR_BACKGROUND_SUBAGENTS_CAPABILITY, AIR_CUSTOM_INSTRUCTIONS_CAPABILITY, airCustomInstructions, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, AIR_GOAL_KEY, AIR_KIND_KEY, AIR_LOAD_REPLAY_END_CAPABILITY, AIR_LOAD_REPLAY_END_KEY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, AIR_SKILL_PATH_KEY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
10
10
  import * as sessionIndex from "./session-index/connection.js";
11
11
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, backgroundedBashToolCallIds, clientSupportsAsyncTasks, } from "./async-tasks.js";
12
+ import { backgroundLevel, backgroundToolCallPause, backgroundToolCallStates, BackgroundWorkReporter, cancelBackgroundWork, droppedTasks, endBackgroundWork, finishBackgroundTerminalOutput, followBackgroundTerminalOutput, registerBackgroundToolCalls, takeBackgroundTerminalExit, takeBackgroundToolCallEnd, } from "./background-work.js";
12
13
  import { AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, authStatusCapability, fromAccountInfo, fromCliStatus, gatewayAuthStatus, mergeAuthStatus, notLoggedInAuthStatus, sameAuthStatus, } from "./auth-status.js";
13
14
  import { execFile } from "node:child_process";
14
15
  import { randomUUID } from "node:crypto";
@@ -36,7 +37,7 @@ import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCom
36
37
  import { clientSupportsNotices, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
37
38
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, hasHookCallback, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, changedTaskPlanEntries, forgetPublishedTaskPlan, unregisterHookCallback, } from "./tools.js";
38
39
  import { previewPatchContent } from "./diff.js";
39
- import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js";
40
+ import { backgroundedBashToolCall, withRunningTerminal, withRunningToolCall, } from "./tool-calls/background.js";
40
41
  import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
41
42
  import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
42
43
  import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
@@ -55,6 +56,7 @@ import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableMode
55
56
  import { GatewayListingError, listBedrockModels, listGatewayModels, PROVIDER_AUTH_TOKEN_PLACEHOLDER, selectBedrockRegion, } from "./gateway-models.js";
56
57
  import { buildEffortConfigOption, EFFORT_CONFIG_ID, effortFlagSettings, effortLevelName, mergeEffortSettings, settingsEffortForModel, } from "./session-effort.js";
57
58
  import { createUltracodeConfigOption, readUltracodeSettings, resolveUltracodeEnabled, ULTRACODE_CONFIG_ID, ultracodeSupported, ultracodeWorkflowsEnabled, } from "./session-ultracode.js";
59
+ import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "./startup-failure.js";
58
60
  export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
59
61
  export { applyAvailableModelsAllowlist, matchResumedModel, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
60
62
  const execFileAsync = promisify(execFile);
@@ -954,6 +956,7 @@ class ClientConnection {
954
956
  requestPermission(params, signal) {
955
957
  return this.ctx.request(methods.client.session.requestPermission, params, {
956
958
  cancellationSignal: signal,
959
+ stopWaitingOnCancel: true,
957
960
  });
958
961
  }
959
962
  readTextFile(params) {
@@ -965,6 +968,7 @@ class ClientConnection {
965
968
  createElicitation(params, signal) {
966
969
  return this.ctx.request(methods.client.elicitation.create, params, {
967
970
  cancellationSignal: signal,
971
+ stopWaitingOnCancel: true,
968
972
  });
969
973
  }
970
974
  completeElicitation(params) {
@@ -1222,6 +1226,8 @@ export class ClaudeAcpAgent {
1222
1226
  replayedChildModels = new Map();
1223
1227
  /** See the `foreground` constructor option. */
1224
1228
  foregroundEvents;
1229
+ /** See the `background` constructor option. */
1230
+ backgroundWork;
1225
1231
  /** Counts the prompts `startTurn()` has received, across sessions. */
1226
1232
  promptsReceived = 0;
1227
1233
  /** Per session, the `promptsReceived` count when its last cancel started.
@@ -1256,6 +1262,7 @@ export class ClaudeAcpAgent {
1256
1262
  constructor(client, logger, options = {}) {
1257
1263
  this.v2 = options.v2 ?? false;
1258
1264
  this.foregroundEvents = options.foreground;
1265
+ this.backgroundWork = new BackgroundWorkReporter(options.background);
1259
1266
  this.sessions = {};
1260
1267
  this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
1261
1268
  this.logger = logger ?? console;
@@ -3431,28 +3438,18 @@ export class ClaudeAcpAgent {
3431
3438
  }
3432
3439
  this.syncAwaitingUser(params.sessionId, session);
3433
3440
  };
3434
- /** Ends the display terminal of a command that went to the background, once
3435
- * its task ends (see `Session.backgroundTerminals`). Claude Code reports no
3436
- * exit code for it, and `completed` does not mean 0: Claude Code accepts
3437
- * some non-zero codes as a success, such as grep's 1. */
3438
- const endBackgroundTerminal = async (taskId) => {
3439
- const terminal = session.backgroundTerminals?.get(taskId);
3440
- if (!terminal)
3441
- return;
3442
- session.backgroundTerminals.delete(taskId);
3443
- await sendUpdate({
3444
- sessionId: params.sessionId,
3445
- update: {
3446
- sessionUpdate: "tool_call_update",
3447
- toolCallId: terminal.toolCallId,
3448
- _meta: {
3449
- ...(terminal.parentToolUseId
3450
- ? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
3451
- : {}),
3452
- terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
3453
- },
3454
- },
3455
- });
3441
+ /** Ends the display terminal of a command that went to the background, and
3442
+ * gives a tool call whose task went on in the background its final
3443
+ * status, once the task ends (see `Session.backgroundTerminals` and
3444
+ * `Session.backgroundToolCalls`). */
3445
+ const endBackgroundTask = async (taskId, end) => {
3446
+ await finishBackgroundTerminalOutput(session, taskId);
3447
+ const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
3448
+ if (exit)
3449
+ await sendUpdate(exit);
3450
+ const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
3451
+ if (toolCallEnd)
3452
+ await sendUpdate(toolCallEnd);
3456
3453
  };
3457
3454
  /** Settles a task. Its request may still be open, and an agent without a
3458
3455
  * record no longer blocks the foreground work, so that is checked again. */
@@ -3932,6 +3929,9 @@ export class ClaudeAcpAgent {
3932
3929
  const pause = messageLoopYielder.maybeYield();
3933
3930
  if (pause)
3934
3931
  await pause;
3932
+ // The last message may have changed the background work, and its
3933
+ // updates are sent, so the list follows them.
3934
+ this.backgroundWork.sync(params.sessionId, session, this.foregroundState(params.sessionId));
3935
3935
  pendingNext ??= session.query
3936
3936
  .next()
3937
3937
  .then((result) => ({ kind: "message", result }));
@@ -4763,7 +4763,7 @@ export class ClaudeAcpAgent {
4763
4763
  streamedBlocksByParent.delete(message.tool_use_id);
4764
4764
  droppedStreams.delete(message.tool_use_id);
4765
4765
  }
4766
- await endBackgroundTerminal(message.task_id);
4766
+ await endBackgroundTask(message.task_id, message.status);
4767
4767
  if (message.status === "stopped") {
4768
4768
  await closeStoppedSubagentToolCalls(message.tool_use_id);
4769
4769
  }
@@ -4797,14 +4797,22 @@ export class ClaudeAcpAgent {
4797
4797
  streamedBlocksByParent.delete(parentToolUseId);
4798
4798
  droppedStreams.delete(parentToolUseId);
4799
4799
  }
4800
- await endBackgroundTerminal(message.task_id);
4800
+ await endBackgroundTask(message.task_id, message.patch.status === "killed" ? "stopped" : message.patch.status);
4801
4801
  if (message.patch.status === "killed") {
4802
4802
  await closeStoppedSubagentToolCalls(parentToolUseId);
4803
4803
  }
4804
4804
  settleLiveTask(message.task_id);
4805
4805
  }
4806
+ else if (message.patch.status === "paused") {
4807
+ const pause = backgroundToolCallPause(session, params.sessionId, message.task_id, true);
4808
+ if (pause)
4809
+ await sendUpdate(pause);
4810
+ }
4806
4811
  else if (message.patch.status === "running" ||
4807
4812
  message.patch.status === "pending") {
4813
+ const resume = backgroundToolCallPause(session, params.sessionId, message.task_id, false);
4814
+ if (resume)
4815
+ await sendUpdate(resume);
4808
4816
  // The SDK can resume a finished subagent under the same
4809
4817
  // agent id without a new task_started.
4810
4818
  resumeLiveTask(message.task_id);
@@ -4958,8 +4966,15 @@ export class ClaudeAcpAgent {
4958
4966
  // control requests, which this adapter never issues.
4959
4967
  case "control_request_progress":
4960
4968
  break;
4961
- case "background_tasks_changed":
4969
+ case "background_tasks_changed": {
4962
4970
  await asyncTasks.backgroundTasksChanged(message.tasks);
4971
+ if (session.reportsBackground) {
4972
+ const level = backgroundLevel(message.tasks);
4973
+ for (const taskId of droppedTasks(session, level)) {
4974
+ await endBackgroundTask(taskId, undefined);
4975
+ }
4976
+ session.backgroundLevel = level;
4977
+ }
4963
4978
  // A level signal: the full live background-task set on every
4964
4979
  // membership change, with REPLACE semantics. Used only to
4965
4980
  // reconcile `liveBackgroundTasks` — dropping (or, for
@@ -5004,6 +5019,7 @@ export class ClaudeAcpAgent {
5004
5019
  }
5005
5020
  }
5006
5021
  break;
5022
+ }
5007
5023
  default:
5008
5024
  unreachable(message, this.logger);
5009
5025
  break;
@@ -6142,6 +6158,8 @@ export class ClaudeAcpAgent {
6142
6158
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
6143
6159
  let backgroundedToolCalls = new Set();
6144
6160
  let runningTerminal;
6161
+ let runningTerminalTask;
6162
+ let runningToolCalls = new Set();
6145
6163
  if (message.type === "user") {
6146
6164
  rememberResolvedToolNames(session, content);
6147
6165
  recordSubagentFileEdits(session.activeTurn, message.parent_tool_use_id, content, session.toolUseCache);
@@ -6162,8 +6180,13 @@ export class ClaudeAcpAgent {
6162
6180
  ...(message.parent_tool_use_id
6163
6181
  ? { parentToolUseId: message.parent_tool_use_id }
6164
6182
  : {}),
6183
+ ...(backgroundBashTask.outputFilePath
6184
+ ? { outputFilePath: backgroundBashTask.outputFilePath }
6185
+ : {}),
6165
6186
  });
6187
+ runningTerminalTask = backgroundBashTask.taskId;
6166
6188
  }
6189
+ runningToolCalls = registerBackgroundToolCalls(session, content, toolUseResult, session.toolUseCache, message.parent_tool_use_id);
6167
6190
  await asyncTasks.toolResults(content);
6168
6191
  backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
6169
6192
  const resumedAgentId = resumedNativeSubagentId(toolUseResult);
@@ -6199,7 +6222,14 @@ export class ClaudeAcpAgent {
6199
6222
  // filtered out of `content` above; blocks that do pass through
6200
6223
  // (e.g. a subagent image) carry the stamped parentToolUseId
6201
6224
  // meta and are excluded there.
6202
- await sendUpdate(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal));
6225
+ await sendUpdate(withRunningToolCall(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal), runningToolCalls));
6226
+ }
6227
+ // After the result's own updates: the command's output replaces
6228
+ // the placeholder that they gave its terminal.
6229
+ if (runningTerminalTask) {
6230
+ followBackgroundTerminalOutput(session, params.sessionId, runningTerminalTask, (notification) => sendUpdate(notification).catch((error) => {
6231
+ this.logger.error(`Session ${params.sessionId}: failed to send background command output:`, error);
6232
+ }));
6203
6233
  }
6204
6234
  break;
6205
6235
  }
@@ -6847,10 +6877,18 @@ export class ClaudeAcpAgent {
6847
6877
  return;
6848
6878
  }
6849
6879
  session.queryClosed = true;
6850
- // Its list row now shows what the registry and the transcript tell.
6851
6880
  for (const [sessionId, candidate] of Object.entries(this.sessions)) {
6852
- if (candidate === session)
6853
- this.sessionIndex.onOwnSessionChanged(sessionId);
6881
+ if (candidate !== session)
6882
+ continue;
6883
+ // Its list row now shows what the registry and the transcript tell.
6884
+ this.sessionIndex.onOwnSessionChanged(sessionId);
6885
+ // Its background tasks end with the query's process.
6886
+ for (const exit of endBackgroundWork(session, sessionId)) {
6887
+ this.client.sessionUpdate(exit).catch((error) => {
6888
+ this.logger.error(`Session ${sessionId}: failed to end a background terminal:`, error);
6889
+ });
6890
+ }
6891
+ this.backgroundWork.sync(sessionId, session, this.foregroundState(sessionId));
6854
6892
  }
6855
6893
  session.consumer = undefined;
6856
6894
  session.contextCompaction = undefined;
@@ -6904,6 +6942,7 @@ export class ClaudeAcpAgent {
6904
6942
  session.asyncTaskRuntime?.clear();
6905
6943
  this.replayedChildModels.delete(sessionId);
6906
6944
  this.cancelledThrough.delete(sessionId);
6945
+ this.backgroundWork.forget(sessionId);
6907
6946
  delete this.sessions[sessionId];
6908
6947
  this.sessionIndex.onTeardown(sessionId);
6909
6948
  const ended = await raceTimeoutAndAbort(turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, new AbortController().signal);
@@ -6942,6 +6981,57 @@ export class ClaudeAcpAgent {
6942
6981
  await this.teardownSession(params.sessionId);
6943
6982
  return {};
6944
6983
  }
6984
+ /** Stops a command running in the background (v2 `terminal/cancel`, see
6985
+ * {@link cancelBackgroundWork}). */
6986
+ cancelBackgroundTerminal(params) {
6987
+ return cancelBackgroundWork(this.sessions, params.sessionId, {
6988
+ terminalId: params.terminalId,
6989
+ });
6990
+ }
6991
+ /** Stops the task of a tool call running in the background (v2
6992
+ * `tool_call/cancel`, see {@link cancelBackgroundWork}). */
6993
+ cancelBackgroundToolCall(params) {
6994
+ return cancelBackgroundWork(this.sessions, params.sessionId, {
6995
+ toolCallId: params.toolCallId,
6996
+ });
6997
+ }
6998
+ /** Sends the current state of each tool call in `toolCallIds` that the root
6999
+ * session `sessionId` runs in the background: the call as its tool use
7000
+ * shows it, with its status now. v2 requires it for each tool call that a
7001
+ * `session/resume` snapshot lists. A failure is logged, as for
7002
+ * {@link publishLiveSubagents}. */
7003
+ async publishBackgroundToolCalls(sessionId, toolCallIds) {
7004
+ const session = this.sessions[sessionId];
7005
+ if (!session)
7006
+ return;
7007
+ const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
7008
+ try {
7009
+ for (const update of backgroundToolCallStates(session, sessionId, toolCallIds, renderer)) {
7010
+ await this.client.sessionUpdate(update);
7011
+ }
7012
+ }
7013
+ catch (error) {
7014
+ this.logger.error(`Session ${sessionId}: failed to send background tool calls:`, error);
7015
+ }
7016
+ }
7017
+ /** Sends the current `subagent_update` of each live subagent session in
7018
+ * `children`, of the session `sessionId` (see
7019
+ * {@link NativeSubagentRuntime.currentUpdate}). v2 requires it for each
7020
+ * child that a `session/resume` snapshot lists. A failure is logged, as
7021
+ * for {@link publishCurrentSubagentStates}. */
7022
+ async publishLiveSubagents(sessionId, children) {
7023
+ const runtime = this.sessions[sessionId]?.nativeSubagentRuntime;
7024
+ try {
7025
+ for (const child of children) {
7026
+ const update = runtime?.currentUpdate(child);
7027
+ if (update)
7028
+ await this.client.sessionUpdate(asSdkSessionNotification(update));
7029
+ }
7030
+ }
7031
+ catch (error) {
7032
+ this.logger.error(`Session ${sessionId}: failed to send current subagent states:`, error);
7033
+ }
7034
+ }
6945
7035
  /** Per client: see {@link sessionIndex.SessionIndexConnection.deleteSession}. */
6946
7036
  async deleteSession(params) {
6947
7037
  return this.sessionIndex.deleteSession(params);
@@ -7739,10 +7829,9 @@ export class ClaudeAcpAgent {
7739
7829
  /** Forward a permission request to the client, wiring the tool call's
7740
7830
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
7741
7831
  * while the client's prompt is still open the signal aborts, the SDK sends
7742
- * `$/cancel_request`, and our local abort race settles even if the client
7743
- * ignores it. A request rejection and a local abort surface the same
7744
- * "Tool use aborted" that the callers expect. `canUseTool` decides what a
7745
- * `cancelled` outcome means. */
7832
+ * `$/cancel_request` and stops waiting for the client's answer. The
7833
+ * rejection surfaces as the "Tool use aborted" that the callers expect.
7834
+ * `canUseTool` decides what a `cancelled` outcome means. */
7746
7835
  async requestPermissionFromClient(params, toolName, toolSignal, parentToolUseId, ownerSessionId, agentID, toolInput, previewContent) {
7747
7836
  const signal = this.untilSessionClosed(ownerSessionId, toolSignal);
7748
7837
  if (signal.aborted)
@@ -7756,13 +7845,10 @@ export class ClaudeAcpAgent {
7756
7845
  await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, toolInput, parentToolUseId, signal, params.sessionId, previewContent);
7757
7846
  if (signal.aborted)
7758
7847
  throw new Error("Tool use aborted");
7759
- // Do not rely on every ACP client settling requestPermission after the
7760
- // cancellation signal. The local race guarantees that Claude's tool call
7761
- // is released even when an older or broken client ignores $/cancel_request.
7762
7848
  // The request goes to a subagent's child session for a tool call of that
7763
7849
  // subagent, but it blocks the turn of the owner session.
7764
7850
  try {
7765
- return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput(ownerSessionId, signal, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
7851
+ return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput(ownerSessionId, signal, () => this.client.requestPermission(params, signal), agentID));
7766
7852
  }
7767
7853
  catch (error) {
7768
7854
  if (signal.aborted) {
@@ -7772,21 +7858,20 @@ export class ClaudeAcpAgent {
7772
7858
  }
7773
7859
  }
7774
7860
  /** Send the client an elicitation for a callback of Claude Code. When the
7775
- * callback's `signal` aborts, the SDK sends `$/cancel_request` and keeps
7776
- * waiting for the client's reply, which a client that ignores the cancel
7777
- * never sends. So the call also rejects at once on the abort, as
7778
- * {@link requestPermissionFromClient} does for a permission request. Left
7779
- * open, the request would stay in `openUserInputs` (see
7780
- * {@link withPendingUserInput}), which keeps steering non-interrupting, and
7781
- * a child's `openRequests` would not drop. */
7861
+ * callback's `signal` aborts, the SDK sends `$/cancel_request` and the call
7862
+ * rejects at once, without waiting for a reply that a client ignoring the
7863
+ * cancel never sends. Left open, the request would stay in
7864
+ * `openUserInputs` (see {@link withPendingUserInput}), which keeps steering
7865
+ * non-interrupting, and a child's `openRequests` would not drop. */
7782
7866
  elicitFromClient(request, signal) {
7783
- return raceWithAbort(this.client.createElicitation(request, signal), signal);
7867
+ return this.client.createElicitation(request, signal);
7784
7868
  }
7785
7869
  /** `signal`, also aborted when `teardownSession` aborts the
7786
7870
  * `abortController` of session `sessionId`. Claude Code aborts a callback's
7787
7871
  * own signal when its query closes, but not necessarily before
7788
- * `session/close` responds, and v2 requires every pending elicitation of
7789
- * the session to be cancelled with `$/cancel_request` before that response. */
7872
+ * `session/close` responds, and v2 requires every pending permission
7873
+ * request and elicitation of the session to be cancelled with
7874
+ * `$/cancel_request` before that response. */
7790
7875
  untilSessionClosed(sessionId, signal) {
7791
7876
  const sessionSignal = this.sessions[sessionId]?.abortController?.signal;
7792
7877
  return sessionSignal ? AbortSignal.any([signal, sessionSignal]) : signal;
@@ -9118,6 +9203,7 @@ export class ClaudeAcpAgent {
9118
9203
  ...providerEnv,
9119
9204
  // Opt-in to session state events like when the agent is idle
9120
9205
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
9206
+ ...STARTUP_FAILURE_RESULTS_ENV,
9121
9207
  };
9122
9208
  // Scopes the context-window cache to this query's backend (see
9123
9209
  // `contextWindowCache`). Derived from the same `env` object handed to the
@@ -9362,7 +9448,7 @@ export class ClaudeAcpAgent {
9362
9448
  error.message.includes("No conversation found with session ID"))) {
9363
9449
  throw RequestError.sessionNotFound(sessionId);
9364
9450
  }
9365
- throw error;
9451
+ throw startupFailureError(error) ?? error;
9366
9452
  }
9367
9453
  timing.phase("sdk-initialize");
9368
9454
  // Publish the identity BEFORE the guard can refuse this session. A
@@ -9586,7 +9672,9 @@ export class ClaudeAcpAgent {
9586
9672
  emittedToolCalls: new Set(),
9587
9673
  eagerToolCallSessions: new Map(),
9588
9674
  liveBackgroundTasks: new Map(),
9675
+ reportsBackground: this.backgroundWork.enabled && options.perTaskStopAffordance === true,
9589
9676
  stopsWorkflowsOnCancel: options.perTaskStopAffordance === true &&
9677
+ !this.backgroundWork.enabled &&
9590
9678
  !clientSupportsAsyncTasks(this.clientCapabilities),
9591
9679
  nativeSubagentsByTaskId: new Map(),
9592
9680
  nativeSubagentTaskIdByToolUseId: new Map(),
@@ -9600,6 +9688,7 @@ export class ClaudeAcpAgent {
9600
9688
  fileChangeReporter,
9601
9689
  };
9602
9690
  timing.phase("register");
9691
+ this.backgroundWork.sync(sessionId, this.sessions[sessionId], this.foregroundState(sessionId));
9603
9692
  this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]);
9604
9693
  return {
9605
9694
  sessionId,
@@ -0,0 +1,54 @@
1
+ /** How often a running command's output file is read. */
2
+ export declare const BACKGROUND_OUTPUT_POLL_MS = 500;
3
+ /** The most bytes one read takes, so a burst of output does not block the loop. */
4
+ export declare const BACKGROUND_OUTPUT_READ_BYTES: number;
5
+ /**
6
+ * The most unread bytes that are still sent. A command that writes faster
7
+ * than the polls read falls behind. Past this, the reads skip to the file's
8
+ * last bytes, as a snapshot: a terminal shows the latest output, and a
9
+ * client keeps a bounded amount of it anyway.
10
+ */
11
+ export declare const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES: number;
12
+ /**
13
+ * Text that the file adds. A snapshot replaces the terminal's output so far:
14
+ * the first one replaces the placeholder of the Bash result, and a later one
15
+ * follows a skip or a file that shrank. Other text appends to it.
16
+ */
17
+ export type BackgroundOutput = {
18
+ text: string;
19
+ replaces: boolean;
20
+ };
21
+ /**
22
+ * Follows the output file `path` of one background command and hands each
23
+ * piece of new text to `publish`, in order. Reads never overlap. A read that
24
+ * fails, for example because the file does not exist yet, is tried again at
25
+ * the next poll. `publish` must not reject: the next read waits for it.
26
+ */
27
+ export declare class BackgroundOutputTail {
28
+ private readonly path;
29
+ private readonly publish;
30
+ private offset;
31
+ private decoder;
32
+ /** Whether the next text replaces the terminal's output. */
33
+ private replaces;
34
+ private stopped;
35
+ private reading;
36
+ private readonly timer;
37
+ constructor(path: string, publish: (output: BackgroundOutput) => Promise<void>);
38
+ /**
39
+ * Reads the rest of the file and stops. The command ended, so what the file
40
+ * holds is its whole output, and it goes out before the terminal's exit.
41
+ */
42
+ finish(): Promise<void>;
43
+ /** Stops without reading more: the session ended. */
44
+ stop(): void;
45
+ /**
46
+ * Reads what the file adds, one read of at most
47
+ * {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
48
+ * text before the next. `toEnd` reads until the end of the file, and ends a
49
+ * character that the command cut off with a replacement character.
50
+ */
51
+ private read;
52
+ private send;
53
+ }
54
+ //# sourceMappingURL=background-output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"background-output.d.ts","sourceRoot":"","sources":["../src/background-output.ts"],"names":[],"mappings":"AAcA,yDAAyD;AACzD,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAE7C,mFAAmF;AACnF,eAAO,MAAM,4BAA4B,QAAa,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,mCAAmC,QAAc,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC;AAEnE;;;;;GAKG;AACH,qBAAa,oBAAoB;IAU7B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAV1B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,OAAO,CAA6B;IAC5C,4DAA4D;IAC5D,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAoC;IACnD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;gBAGpC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC;IAOvE;;;OAGG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAM7B,qDAAqD;IACrD,IAAI,IAAI,IAAI;IAKZ;;;;;OAKG;IACH,OAAO,CAAC,IAAI;YA4CE,IAAI;CAKnB"}
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The output of a command that went to the background, read from the file
3
+ * that Claude Code writes it to. Claude Code streams no output of such a
4
+ * command, so its display terminal would show only the placeholder of the
5
+ * Bash result ("Command running in background with ID …") until the command
6
+ * exits. ACP v2 asks an Agent to keep a listed terminal's output current
7
+ * whenever it can read it (the v2 prompt lifecycle, "Background work").
8
+ *
9
+ * The file is polled: watching a file is not reliable on every platform, and
10
+ * a missing file only means that nothing was written yet.
11
+ */
12
+ import { open } from "node:fs/promises";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ /** How often a running command's output file is read. */
15
+ export const BACKGROUND_OUTPUT_POLL_MS = 500;
16
+ /** The most bytes one read takes, so a burst of output does not block the loop. */
17
+ export const BACKGROUND_OUTPUT_READ_BYTES = 256 * 1024;
18
+ /**
19
+ * The most unread bytes that are still sent. A command that writes faster
20
+ * than the polls read falls behind. Past this, the reads skip to the file's
21
+ * last bytes, as a snapshot: a terminal shows the latest output, and a
22
+ * client keeps a bounded amount of it anyway.
23
+ */
24
+ export const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES = 1024 * 1024;
25
+ /**
26
+ * Follows the output file `path` of one background command and hands each
27
+ * piece of new text to `publish`, in order. Reads never overlap. A read that
28
+ * fails, for example because the file does not exist yet, is tried again at
29
+ * the next poll. `publish` must not reject: the next read waits for it.
30
+ */
31
+ export class BackgroundOutputTail {
32
+ path;
33
+ publish;
34
+ offset = 0;
35
+ decoder = new StringDecoder("utf8");
36
+ /** Whether the next text replaces the terminal's output. */
37
+ replaces = true;
38
+ stopped = false;
39
+ reading = Promise.resolve();
40
+ timer;
41
+ constructor(path, publish) {
42
+ this.path = path;
43
+ this.publish = publish;
44
+ this.timer = setInterval(() => void this.read(false), BACKGROUND_OUTPUT_POLL_MS);
45
+ // A command that runs on must not keep the agent process alive.
46
+ this.timer.unref();
47
+ }
48
+ /**
49
+ * Reads the rest of the file and stops. The command ended, so what the file
50
+ * holds is its whole output, and it goes out before the terminal's exit.
51
+ */
52
+ async finish() {
53
+ clearInterval(this.timer);
54
+ await this.read(true);
55
+ this.stopped = true;
56
+ }
57
+ /** Stops without reading more: the session ended. */
58
+ stop() {
59
+ clearInterval(this.timer);
60
+ this.stopped = true;
61
+ }
62
+ /**
63
+ * Reads what the file adds, one read of at most
64
+ * {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
65
+ * text before the next. `toEnd` reads until the end of the file, and ends a
66
+ * character that the command cut off with a replacement character.
67
+ */
68
+ read(toEnd) {
69
+ this.reading = this.reading.then(async () => {
70
+ if (this.stopped)
71
+ return;
72
+ let file;
73
+ try {
74
+ file = await open(this.path, "r");
75
+ }
76
+ catch {
77
+ // Nothing written yet, or the file is gone: try again at the next
78
+ // poll. At the end, the terminal keeps the output it has.
79
+ return;
80
+ }
81
+ try {
82
+ const { size } = await file.stat();
83
+ // A file that shrank is a new one: its output starts over. Past the
84
+ // backlog, the reads skip to the file's last bytes.
85
+ const start = size < this.offset ? 0 : this.offset;
86
+ const skipTo = Math.max(start, size - BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
87
+ if (skipTo !== this.offset) {
88
+ this.offset = skipTo;
89
+ this.decoder = new StringDecoder("utf8");
90
+ this.replaces = true;
91
+ }
92
+ const buffer = Buffer.alloc(BACKGROUND_OUTPUT_READ_BYTES);
93
+ let bytesRead;
94
+ do {
95
+ ({ bytesRead } = await file.read(buffer, 0, buffer.length, this.offset));
96
+ this.offset += bytesRead;
97
+ const text = this.decoder.write(buffer.subarray(0, bytesRead));
98
+ if (text !== "")
99
+ await this.send(text);
100
+ } while (toEnd && bytesRead > 0);
101
+ if (toEnd) {
102
+ const rest = this.decoder.end();
103
+ // A command that wrote nothing still replaces the placeholder.
104
+ if (rest !== "" || this.replaces)
105
+ await this.send(rest);
106
+ }
107
+ }
108
+ catch {
109
+ // The read failed: try again at the next poll.
110
+ }
111
+ finally {
112
+ await file.close();
113
+ }
114
+ });
115
+ return this.reading;
116
+ }
117
+ async send(text) {
118
+ const replaces = this.replaces;
119
+ this.replaces = false;
120
+ await this.publish({ text, replaces });
121
+ }
122
+ }