@lucascouts/claude-agent-acp-plus 0.23.6 → 0.25.0

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
@@ -42,6 +42,8 @@ import { ACCOUNT_CONFIG_ID, accountForValue, accountInForce, createAccountConfig
42
42
  import { isUsageCommandText, structuredUsageMarkdown, syntheticLocalCommand, } from "./usage-markdown.js";
43
43
  export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
44
44
  import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
45
+ import { TASKS_META_KEY, TaskFeed, TaskFeedPublisher } from "./task-feed.js";
46
+ import { EscalationRegistry, GrantStore, createAskOperator, createGrantPreToolUseHook, createPermissionDeniedHook, } from "./classifier-escalation.js";
45
47
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
46
48
  const execFileAsync = promisify(execFile);
47
49
  const ZERO_USAGE = Object.freeze({
@@ -103,6 +105,10 @@ const STEER_PRIORITY_LATER = "later";
103
105
  * reports in `background_tasks_changed`, so a client can tell an idle thread
104
106
  * from one whose turn ended while a shell or subagent keeps running. */
105
107
  export const BACKGROUND_TASKS_META_KEY = "_claude/backgroundTasks";
108
+ /** `session_info_update._meta` key carrying the task feed snapshot
109
+ * (story 012) — see `task-feed.ts`. Published beside, never instead of,
110
+ * {@link BACKGROUND_TASKS_META_KEY}. */
111
+ export { TASKS_META_KEY };
106
112
  /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
107
113
  * content blocks are handed to `promptToClaude`, which tolerates unknown block
108
114
  * types — but `sessionId` and a non-empty `prompt` array are required. */
@@ -130,6 +136,48 @@ function parseSteerRequest(params) {
130
136
  _meta: _meta,
131
137
  };
132
138
  }
139
+ /** Stop one of the agent's tasks (story 012, R2.1). Params
140
+ * {@link TasksStopRequest}, result `{}`. */
141
+ export const TASKS_STOP_METHOD = "_claude/tasks/stop";
142
+ /** Move a foreground tool call's task to the background (story 012, R2.3).
143
+ * Params {@link TasksBackgroundRequest}, result {@link TasksBackgroundResponse}. */
144
+ export const TASKS_BACKGROUND_METHOD = "_claude/tasks/background";
145
+ /** `agentCapabilities._meta` key advertising the task feed and its two
146
+ * methods (R2.7). Zeo gates on this, never on the agent's name — the name is
147
+ * `package.json`'s, and differs between this fork and the published mirror. */
148
+ export const TASKS_CAPABILITY_KEY = TASKS_META_KEY;
149
+ export const TASKS_CAPABILITY_VERSION = 1;
150
+ /** The SDK's own text for a rejected control request, passed through to the
151
+ * client as the internal error's message (R2.4). */
152
+ function errorMessage(error) {
153
+ return error instanceof Error ? error.message : String(error);
154
+ }
155
+ /** A value made safe for one `key=value` field: ids come from the client and
156
+ * could otherwise carry a space, a newline or an `=` that forges a field. */
157
+ function logValue(value) {
158
+ return value.replace(/[\s=]+/g, "_");
159
+ }
160
+ function requireIdField(params, method, field) {
161
+ const value = params && typeof params === "object" ? params[field] : undefined;
162
+ if (typeof value !== "string" || value.length === 0) {
163
+ throw RequestError.invalidParams(undefined, `${method} params require a non-empty ${field}`);
164
+ }
165
+ return value;
166
+ }
167
+ /** Validate raw JSON-RPC params into a {@link TasksStopRequest}. */
168
+ export function parseTasksStopRequest(params) {
169
+ return {
170
+ sessionId: requireIdField(params, TASKS_STOP_METHOD, "sessionId"),
171
+ taskId: requireIdField(params, TASKS_STOP_METHOD, "taskId"),
172
+ };
173
+ }
174
+ /** Validate raw JSON-RPC params into a {@link TasksBackgroundRequest}. */
175
+ export function parseTasksBackgroundRequest(params) {
176
+ return {
177
+ sessionId: requireIdField(params, TASKS_BACKGROUND_METHOD, "sessionId"),
178
+ toolCallId: requireIdField(params, TASKS_BACKGROUND_METHOD, "toolCallId"),
179
+ };
180
+ }
133
181
  /** How often the account quota windows are refreshed while a session sits idle,
134
182
  * in milliseconds; `0` disables polling entirely.
135
183
  *
@@ -144,6 +192,14 @@ function parseSteerRequest(params) {
144
192
  * loop to show anything a 15-second one would miss. */
145
193
  const QUOTA_POLL_DEFAULT_MS = 60_000;
146
194
  const QUOTA_POLL_FLOOR_MS = 15_000;
195
+ /** Whether sessions ask the SDK for progress summaries on subagent tasks
196
+ * (`CLAUDE_ACP_PROGRESS_SUMMARIES`). Only `1` or `true` turn it on; anything
197
+ * else — unset included — leaves it off, because each summary is a model
198
+ * call the user pays for. */
199
+ export function progressSummariesEnabled(raw = process.env.CLAUDE_ACP_PROGRESS_SUMMARIES) {
200
+ const value = raw?.trim().toLowerCase();
201
+ return value === "1" || value === "true";
202
+ }
147
203
  /** Resolve the poll interval from the environment, clamped. An unparseable or
148
204
  * negative value falls back to the default rather than disabling the refresh:
149
205
  * a typo should not silently return the bars to the stale behaviour this
@@ -249,6 +305,15 @@ function stripSubagentTextAndThinking(content) {
249
305
  !("type" in item) ||
250
306
  (item.type !== "text" && item.type !== "thinking"));
251
307
  }
308
+ /** A cached tool call's `description` input, when it is a string. */
309
+ function toolDescriptionOf(cache, toolUseId) {
310
+ if (typeof toolUseId !== "string")
311
+ return undefined;
312
+ const input = cache[toolUseId]?.input;
313
+ if (typeof input !== "object" || input === null || !("description" in input))
314
+ return undefined;
315
+ return typeof input.description === "string" ? input.description : undefined;
316
+ }
252
317
  /**
253
318
  * Advance the lexer state across the fragment appended since the last delta:
254
319
  * just enough JSON awareness (string/escape, nesting depth) to spot commas
@@ -664,13 +729,13 @@ export class ClaudeAcpAgent {
664
729
  },
665
730
  settleCancelledTurn: (original, session, turn) => {
666
731
  disarmForceCancel(session);
667
- this.finishFileChangeAudit(session, turn, "cancelled");
732
+ this.endTurnScope(session, turn, "cancelled");
668
733
  turn.settled = true;
669
734
  turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
670
735
  },
671
736
  settleFailedTurn: (session, turn, error) => {
672
737
  disarmForceCancel(session);
673
- this.finishFileChangeAudit(session, turn, "providerError");
738
+ this.endTurnScope(session, turn, "providerError");
674
739
  turn.settled = true;
675
740
  turn.reject(error);
676
741
  },
@@ -789,6 +854,7 @@ export class ClaudeAcpAgent {
789
854
  claudeCode: {
790
855
  promptQueueing: true,
791
856
  },
857
+ [TASKS_CAPABILITY_KEY]: { version: TASKS_CAPABILITY_VERSION },
792
858
  },
793
859
  promptCapabilities: {
794
860
  image: true,
@@ -1438,6 +1504,59 @@ export class ClaudeAcpAgent {
1438
1504
  * stays Host-owned so the Host can submit it through a standard
1439
1505
  * `session/prompt`. Without the opt-in, the existing detached `prompt()` and
1440
1506
  * `startedNewTurn` result are preserved for compatibility. */
1507
+ /** `_claude/tasks/stop`: stop a task the feed holds as running (R2.1, R2.2). */
1508
+ async stopTask(params) {
1509
+ const { sessionId, taskId } = params;
1510
+ return this.taskAction(TASKS_STOP_METHOD, sessionId, `taskId=${logValue(taskId)}`, async (session) => {
1511
+ if (!session.taskFeed?.isRunning(taskId)) {
1512
+ throw RequestError.invalidParams(undefined, `task ${taskId} is not running`);
1513
+ }
1514
+ try {
1515
+ await session.query.stopTask(taskId);
1516
+ }
1517
+ catch (error) {
1518
+ throw RequestError.internalError(undefined, errorMessage(error));
1519
+ }
1520
+ return {};
1521
+ });
1522
+ }
1523
+ /** `_claude/tasks/background`: background the task a foreground tool call
1524
+ * started (R2.3, R2.4). `false` means the id matched no foreground task. */
1525
+ async backgroundTask(params) {
1526
+ const { sessionId, toolCallId } = params;
1527
+ return this.taskAction(TASKS_BACKGROUND_METHOD, sessionId, `toolCallId=${logValue(toolCallId)}`, async (session) => {
1528
+ try {
1529
+ return { backgrounded: await session.query.backgroundTasks(toolCallId) };
1530
+ }
1531
+ catch (error) {
1532
+ // Thrown when background tasks are disabled for the session.
1533
+ throw RequestError.internalError(undefined, errorMessage(error));
1534
+ }
1535
+ });
1536
+ }
1537
+ /** Resolve the session, run the action, and write its one `[tasks/action]`
1538
+ * line (R2.6). The line carries ids and the outcome only — never a command
1539
+ * line, a description or a summary. */
1540
+ async taskAction(method, sessionId, target, action) {
1541
+ const log = (outcome, reason) => this.logger.log(`[tasks/action] method=${method} sessionId=${logValue(sessionId)} ${target} outcome=${outcome}` +
1542
+ (reason ? ` reason=${logValue(reason)}` : ""));
1543
+ try {
1544
+ const session = this.sessions[sessionId];
1545
+ if (!session) {
1546
+ throw RequestError.invalidParams(undefined, `unknown session ${sessionId}`);
1547
+ }
1548
+ if (session.queryClosed) {
1549
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1550
+ }
1551
+ const result = await action(session);
1552
+ log("ok");
1553
+ return result;
1554
+ }
1555
+ catch (error) {
1556
+ log("error", error instanceof RequestError ? `code_${error.code}` : "unexpected");
1557
+ throw error;
1558
+ }
1559
+ }
1441
1560
  async steer(params) {
1442
1561
  const sessionId = params.sessionId;
1443
1562
  const session = this.sessions[sessionId];
@@ -1500,6 +1619,14 @@ export class ClaudeAcpAgent {
1500
1619
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1501
1620
  return { outcome: "injected" };
1502
1621
  }
1622
+ /** Everything scoped to one turn, released on every path that settles it
1623
+ * (result, cancel, failure, stream death): story 016's unused "Yes" grants
1624
+ * (R2.4), then the file-change audit terminal. Every turn-settle site calls
1625
+ * this right before flipping `settled`. */
1626
+ endTurnScope(session, turn, reason) {
1627
+ session.classifierGrants.clearOnce();
1628
+ this.finishFileChangeAudit(session, turn, reason);
1629
+ }
1503
1630
  /** Publish the audit terminal for every turn path that did not reach the
1504
1631
  * report tool. The support flips the turn state synchronously before its
1505
1632
  * transport await, so callers can stay fail-open and settle the ACP prompt
@@ -2068,7 +2195,7 @@ export class ClaudeAcpAgent {
2068
2195
  if (!turn || turn.settled) {
2069
2196
  return;
2070
2197
  }
2071
- this.finishFileChangeAudit(session, turn, auditReason);
2198
+ this.endTurnScope(session, turn, auditReason);
2072
2199
  // Captured before the settled flip below (isHeldOpen tests !settled).
2073
2200
  const wasHeld = isHeldOpen(turn);
2074
2201
  turn.settled = true;
@@ -2105,7 +2232,7 @@ export class ClaudeAcpAgent {
2105
2232
  this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
2106
2233
  return;
2107
2234
  }
2108
- this.finishFileChangeAudit(session, turn, "providerError");
2235
+ this.endTurnScope(session, turn, "providerError");
2109
2236
  turn.settled = true;
2110
2237
  turn.usageMarkdownAbort?.abort();
2111
2238
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
@@ -2157,7 +2284,7 @@ export class ClaudeAcpAgent {
2157
2284
  session.turnQueue = [];
2158
2285
  for (const turn of turns) {
2159
2286
  if (!turn.settled) {
2160
- this.finishFileChangeAudit(session, turn, "providerError");
2287
+ this.endTurnScope(session, turn, "providerError");
2161
2288
  const wasHeld = isHeldOpen(turn);
2162
2289
  turn.settled = true;
2163
2290
  turn.usageMarkdownAbort?.abort();
@@ -2351,7 +2478,7 @@ export class ClaudeAcpAgent {
2351
2478
  // still here was enqueued afterward and was not part of the cancel.)
2352
2479
  for (const queued of [...(session.turnQueue ?? [])]) {
2353
2480
  if (!queued.settled) {
2354
- this.finishFileChangeAudit(session, queued, "providerError");
2481
+ this.endTurnScope(session, queued, "providerError");
2355
2482
  queued.settled = true;
2356
2483
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
2357
2484
  }
@@ -2838,9 +2965,7 @@ export class ClaudeAcpAgent {
2838
2965
  // fallback in `toAcpNotifications` gates on `wasEmitted` for.
2839
2966
  // Drop the update rather than reference a tool call the client
2840
2967
  // was never given (see `ensureToolCallEmitted`, issue #851).
2841
- if (!session.emittedToolCalls.has(message.tool_use_id)) {
2842
- break;
2843
- }
2968
+ //
2844
2969
  // A denial inside a subagent identifies the subagent by
2845
2970
  // `agent_id` (as canUseTool does with `agentID`), never by the
2846
2971
  // Agent/Task call that spawned it. Resolve it the same way so
@@ -2851,6 +2976,37 @@ export class ClaudeAcpAgent {
2851
2976
  ? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
2852
2977
  : undefined;
2853
2978
  const reason = message.decision_reason ?? message.message;
2979
+ const meta = {
2980
+ claudeCode: {
2981
+ toolName: message.tool_name,
2982
+ ...(parentToolUseId ? { parentToolUseId } : {}),
2983
+ toolResponse: {
2984
+ decisionReasonType: message.decision_reason_type,
2985
+ decisionReason: message.decision_reason,
2986
+ message: message.message,
2987
+ },
2988
+ },
2989
+ };
2990
+ // Story 016: every frame is noted first, whatever its reason
2991
+ // type — the `PermissionDenied` hook may be waiting on it to
2992
+ // learn whether this denial is the classifier's.
2993
+ session.escalations.noteFrame({
2994
+ toolUseId: message.tool_use_id,
2995
+ reasonType: message.decision_reason_type,
2996
+ reason,
2997
+ agentId: message.agent_id,
2998
+ meta,
2999
+ emitted: session.emittedToolCalls.has(message.tool_use_id),
3000
+ });
3001
+ // A classifier denial asks the operator instead of failing:
3002
+ // the call stays pending, and the registry sends its one final
3003
+ // update (approved, rejected with today's text, or cancelled).
3004
+ if (message.decision_reason_type === "classifier") {
3005
+ break;
3006
+ }
3007
+ if (!session.emittedToolCalls.has(message.tool_use_id)) {
3008
+ break;
3009
+ }
2854
3010
  await sendUpdate({
2855
3011
  sessionId: params.sessionId,
2856
3012
  update: {
@@ -2863,17 +3019,7 @@ export class ClaudeAcpAgent {
2863
3019
  content: { type: "text", text: `Permission denied: ${reason}` },
2864
3020
  },
2865
3021
  ],
2866
- _meta: {
2867
- claudeCode: {
2868
- toolName: message.tool_name,
2869
- ...(parentToolUseId ? { parentToolUseId } : {}),
2870
- toolResponse: {
2871
- decisionReasonType: message.decision_reason_type,
2872
- decisionReason: message.decision_reason,
2873
- message: message.message,
2874
- },
2875
- },
2876
- },
3022
+ _meta: meta,
2877
3023
  },
2878
3024
  });
2879
3025
  break;
@@ -2928,7 +3074,9 @@ export class ClaudeAcpAgent {
2928
3074
  case "hook_progress":
2929
3075
  case "hook_response":
2930
3076
  case "files_persisted":
3077
+ break;
2931
3078
  case "task_progress":
3079
+ session.taskFeedPublisher?.notify(session.taskFeed.onProgress(message));
2932
3080
  break;
2933
3081
  case "task_started":
2934
3082
  // For subagent tasks `task_id` is the subagent's agent id (the
@@ -2956,11 +3104,17 @@ export class ClaudeAcpAgent {
2956
3104
  if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
2957
3105
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
2958
3106
  }
3107
+ // The spawning tool call's own `description` labels a shell
3108
+ // task instead of its command line. Read it now: the cache
3109
+ // entry is pruned at `tool_result`, which for a background
3110
+ // Bash arrives almost at once.
3111
+ session.taskFeedPublisher?.notify(session.taskFeed.onStarted(message, toolDescriptionOf(session.toolUseCache, message.tool_use_id)));
2959
3112
  break;
2960
3113
  case "task_notification":
2961
3114
  // The task settled — no further tool calls can originate
2962
3115
  // from it, so its registry entry can be dropped.
2963
3116
  session.liveBackgroundTasks.delete(message.task_id);
3117
+ session.taskFeedPublisher?.notify(session.taskFeed.onNotification(message));
2964
3118
  break;
2965
3119
  case "task_updated":
2966
3120
  // terminal-status task_updated patch and a (deduplicated)
@@ -2973,6 +3127,7 @@ export class ClaudeAcpAgent {
2973
3127
  message.patch.status === "killed") {
2974
3128
  session.liveBackgroundTasks.delete(message.task_id);
2975
3129
  }
3130
+ session.taskFeedPublisher?.notify(session.taskFeed.onUpdated(message));
2976
3131
  break;
2977
3132
  case "worker_shutting_down":
2978
3133
  // Defer until stream end. The announcement is durable and may be
@@ -3165,6 +3320,7 @@ export class ClaudeAcpAgent {
3165
3320
  // (watchers, skip_transcript work) are excluded because the
3166
3321
  // SDK marks them as "not activity" for exactly this use.
3167
3322
  await publishBackgroundTasks(message.tasks.filter((task) => !task.ambient));
3323
+ session.taskFeedPublisher?.notify(session.taskFeed.onLevel(message.tasks));
3168
3324
  break;
3169
3325
  default:
3170
3326
  unreachable(message, this.logger);
@@ -4107,6 +4263,15 @@ export class ClaudeAcpAgent {
4107
4263
  ? message.tool_result_meta
4108
4264
  : undefined,
4109
4265
  })) {
4266
+ // Story 016 (R2.5): the CLI still sends an `is_error` result for
4267
+ // a classifier-denied call the operator approved after the hook
4268
+ // returns. The registry already finished that call with
4269
+ // "approved, will run again"; a failed update now would
4270
+ // contradict it, so it is not sent.
4271
+ if (notification.update.sessionUpdate === "tool_call_update" &&
4272
+ session.escalations.wasApproved(notification.update.toolCallId)) {
4273
+ continue;
4274
+ }
4110
4275
  // sendUpdate records delivery. Subagent text/thinking is
4111
4276
  // filtered out of `content` above; blocks that do pass through
4112
4277
  // (e.g. a subagent image) carry the stamped parentToolUseId
@@ -4366,7 +4531,7 @@ export class ClaudeAcpAgent {
4366
4531
  if (session.turnQueue) {
4367
4532
  for (const turn of session.turnQueue) {
4368
4533
  if (turn !== session.activeTurn && !turn.settled) {
4369
- this.finishFileChangeAudit(session, turn, "cancelled");
4534
+ this.endTurnScope(session, turn, "cancelled");
4370
4535
  turn.settled = true;
4371
4536
  // Deliberately no `usage`: a queued turn never ran, so the session
4372
4537
  // accumulator (the active turn's tally) is not its spend.
@@ -4432,7 +4597,7 @@ export class ClaudeAcpAgent {
4432
4597
  {
4433
4598
  const active = session.activeTurn;
4434
4599
  if (isHeldOpen(active)) {
4435
- this.finishFileChangeAudit(session, active, "cancelled");
4600
+ this.endTurnScope(session, active, "cancelled");
4436
4601
  active.settled = true;
4437
4602
  // Mirror settleActive's invariants (it is consumer-scoped and
4438
4603
  // unreachable from here): disarm the backstop — none should be
@@ -4585,6 +4750,40 @@ export class ClaudeAcpAgent {
4585
4750
  session.settingsManager.dispose();
4586
4751
  session.input.end();
4587
4752
  session.query.close();
4753
+ this.retireTaskFeed(session);
4754
+ // Story 016 (R3.2): grants and escalation records die with the stream —
4755
+ // the same reason the quota timer is cleared here. A replacement under the
4756
+ // same id (clear-context restart, provider update) starts with fresh ones.
4757
+ session.classifierGrants.clear();
4758
+ session.escalations.clear();
4759
+ }
4760
+ /** The session's CLI process is gone for good: whatever it was running did
4761
+ * not survive (R1.9), and the publisher must not outlive the session object
4762
+ * — a clear-context restart or a provider switch replaces that object under
4763
+ * the SAME id, and a stale timer would overwrite the new feed's level. The
4764
+ * query recreate is the one exit that keeps the feed, and does not come
4765
+ * through here. */
4766
+ retireTaskFeed(session) {
4767
+ const publisher = session.taskFeedPublisher;
4768
+ if (!publisher)
4769
+ return;
4770
+ publisher.notify(session.taskFeed.onProcessRestart());
4771
+ publisher.dispose({ flush: true });
4772
+ }
4773
+ /** One session's task feed and its publisher (story 012). The send goes
4774
+ * straight to the client rather than through a consumer's `sendUpdate`:
4775
+ * the publisher outlives consumers (a query recreate replaces them), and a
4776
+ * `session_info_update` is never subject to that chokepoint's filters. */
4777
+ createTaskFeed(sessionId) {
4778
+ const taskFeed = new TaskFeed(() => Date.now(),
4779
+ // Degraded but self-recovered: the Logger has no warn level, so the
4780
+ // line carries it as a field rather than being raised to error.
4781
+ { warn: (message) => this.logger.log(`${message} level=warn`) }, sessionId);
4782
+ const taskFeedPublisher = new TaskFeedPublisher(taskFeed, (snapshot) => this.client.sessionUpdate({
4783
+ sessionId,
4784
+ update: { sessionUpdate: "session_info_update", _meta: { [TASKS_META_KEY]: snapshot } },
4785
+ }), undefined, (error) => this.logger.error(`[tasks/feed] sessionId=${logValue(sessionId)} outcome=send_failed reason=${logValue(errorMessage(error))}`));
4786
+ return { taskFeed, taskFeedPublisher };
4588
4787
  }
4589
4788
  /** Cleanly tear down a session: cancel in-flight work, release stream
4590
4789
  * resources, and remove it from the session map. */
@@ -4614,6 +4813,9 @@ export class ClaudeAcpAgent {
4614
4813
  // here the client has asked us to close the session, so signalling abort is
4615
4814
  // appropriate; query.close() above has already torn the subprocess down.
4616
4815
  session.abortController.abort();
4816
+ // A pending throttle, retry or reconcile timer must not send to a closed
4817
+ // session.
4818
+ session.taskFeedPublisher?.dispose();
4617
4819
  delete this.sessions[sessionId];
4618
4820
  }
4619
4821
  /** Tear down all active sessions. Called when the ACP connection closes. */
@@ -4944,7 +5146,10 @@ export class ClaudeAcpAgent {
4944
5146
  * while the client's prompt is still open the signal aborts, the SDK sends
4945
5147
  * `$/cancel_request`, and our local abort race settles even if the client
4946
5148
  * ignores it. A `cancelled` outcome, request rejection, and local abort all
4947
- * surface the same "Tool use aborted" the callers already expect. */
5149
+ * surface the same "Tool use aborted" the callers already expect.
5150
+ * Not private: the classifier-escalation question (story 016,
5151
+ * `createAskOperator`) goes through it too, for the same emitted tool call
5152
+ * and the same pending-user-input accounting. */
4948
5153
  async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
4949
5154
  if (signal.aborted)
4950
5155
  throw new Error("Tool use aborted");
@@ -5754,6 +5959,10 @@ export class ClaudeAcpAgent {
5754
5959
  // after the swap above is complete.
5755
5960
  oldInput.end();
5756
5961
  oldQuery.close();
5962
+ // Whatever the old process was running did not survive it (R1.9). The
5963
+ // feed and publisher are the session's, so the endings go out through
5964
+ // the same throttle the replacement query keeps using.
5965
+ session.taskFeedPublisher?.notify(session.taskFeed.onProcessRestart());
5757
5966
  }
5758
5967
  catch (error) {
5759
5968
  // Keep the OLD query fully usable and the pending flag set (the next
@@ -6016,6 +6225,24 @@ export class ClaudeAcpAgent {
6016
6225
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6017
6226
  // the same Map that the streaming message handler will read from.
6018
6227
  const taskState = new Map();
6228
+ // Story 016: the grant store and the escalation registry are built before
6229
+ // the options for the same reason — the PermissionDenied and grant hooks
6230
+ // close over them, and the session record below adopts these very objects.
6231
+ const classifierGrants = new GrantStore();
6232
+ const escalations = new EscalationRegistry({
6233
+ sessionId,
6234
+ // The final update carries the frame's own `_meta` (tool name, parent
6235
+ // stamp resolved through `liveBackgroundTasks`), so it lands in the
6236
+ // same transcript as today's failed update would.
6237
+ emit: (_toolUseId, update) => {
6238
+ // The same suppression the prompt-scoped `sendUpdate` applies.
6239
+ if (isFileChangeAuditReportPhase(this.sessions[sessionId]?.activeTurn?.fileChangeAudit)) {
6240
+ return;
6241
+ }
6242
+ return this.client.sessionUpdate({ sessionId, update });
6243
+ },
6244
+ logger: this.logger,
6245
+ });
6019
6246
  // Resolve every workspace root once. The hidden report tool uses this same
6020
6247
  // set for lexical path validation, and the SDK receives it below.
6021
6248
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
@@ -6131,6 +6358,10 @@ export class ClaudeAcpAgent {
6131
6358
  // (story 006, R3.3). Placed before the user spread so an explicit user
6132
6359
  // `enableFileCheckpointing` still wins.
6133
6360
  enableFileCheckpointing: true,
6361
+ // Model-written one-line summaries on a subagent's `task_progress`
6362
+ // (story 012, R3), opt-in because each costs tokens. Before the user
6363
+ // spread, so an explicit `agentProgressSummaries` still wins.
6364
+ agentProgressSummaries: progressSummariesEnabled(),
6134
6365
  ...userProvidedOptions,
6135
6366
  ...(settings && { settings }),
6136
6367
  env,
@@ -6178,14 +6409,14 @@ export class ClaudeAcpAgent {
6178
6409
  tools,
6179
6410
  hooks: {
6180
6411
  ...userProvidedOptions?.hooks,
6181
- ...(fileChangeAuditSupport
6182
- ? {
6183
- PreToolUse: [
6184
- ...(userProvidedOptions?.hooks?.PreToolUse || []),
6185
- { hooks: [fileChangeAuditSupport.preToolUseHook] },
6186
- ],
6187
- }
6188
- : {}),
6412
+ // Story 016: the grant hook runs last, after the user's hooks and the
6413
+ // file-change audit hook, so it can only add an allow for a call the
6414
+ // operator approved — never pre-empt what comes before it.
6415
+ PreToolUse: [
6416
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
6417
+ ...(fileChangeAuditSupport ? [{ hooks: [fileChangeAuditSupport.preToolUseHook] }] : []),
6418
+ { hooks: [createGrantPreToolUseHook({ sessionId, classifierGrants }, this.logger)] },
6419
+ ],
6189
6420
  PostToolUse: [
6190
6421
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
6191
6422
  {
@@ -6229,6 +6460,25 @@ export class ClaudeAcpAgent {
6229
6460
  ],
6230
6461
  },
6231
6462
  ],
6463
+ // Story 016: a classifier denial becomes a question to the operator.
6464
+ // `timeout` is in seconds: the CLI default is far shorter than a human
6465
+ // answer, and the hook bounds itself at 590 s so the adapter decides.
6466
+ // The operator is resolved by session id at ask time; a session gone
6467
+ // by then fails the ask, and the hook grants nothing (fail closed).
6468
+ PermissionDenied: [
6469
+ ...(userProvidedOptions?.hooks?.PermissionDenied || []),
6470
+ {
6471
+ hooks: [
6472
+ createPermissionDeniedHook({
6473
+ session: { sessionId, classifierGrants },
6474
+ logger: this.logger,
6475
+ escalations,
6476
+ askOperator: createAskOperator(this, sessionId),
6477
+ }),
6478
+ ],
6479
+ timeout: 600,
6480
+ },
6481
+ ],
6232
6482
  },
6233
6483
  ...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
6234
6484
  ...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
@@ -6503,7 +6753,10 @@ export class ClaudeAcpAgent {
6503
6753
  taskState,
6504
6754
  toolUseCache: {},
6505
6755
  emittedToolCalls: new Set(),
6756
+ escalations,
6757
+ classifierGrants,
6506
6758
  liveBackgroundTasks: new Map(),
6759
+ ...this.createTaskFeed(sessionId),
6507
6760
  emittedAssistantText: false,
6508
6761
  owedTrailingIdles: 0,
6509
6762
  messageIdToUuid: new Map(),
@@ -8392,6 +8645,8 @@ export function runAcp(logger) {
8392
8645
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
8393
8646
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
8394
8647
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
8648
+ .onRequest(TASKS_STOP_METHOD, { parse: parseTasksStopRequest }, (ctx) => agent.stopTask(ctx.params))
8649
+ .onRequest(TASKS_BACKGROUND_METHOD, { parse: parseTasksBackgroundRequest }, (ctx) => agent.backgroundTask(ctx.params))
8395
8650
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
8396
8651
  .connect(stream);
8397
8652
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);