@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/README.md +54 -0
- package/dist/acp-agent.d.ts +85 -2
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +286 -31
- package/dist/classifier-escalation.d.ts +158 -0
- package/dist/classifier-escalation.d.ts.map +1 -0
- package/dist/classifier-escalation.js +562 -0
- package/dist/permissions/options/shared.d.ts +8 -0
- package/dist/permissions/options/shared.d.ts.map +1 -1
- package/dist/permissions/options/shared.js +18 -0
- package/dist/permissions/presentation.d.ts +1 -0
- package/dist/permissions/presentation.d.ts.map +1 -1
- package/dist/permissions/presentation.js +1 -1
- package/dist/permissions/response.d.ts +6 -0
- package/dist/permissions/response.d.ts.map +1 -1
- package/dist/permissions/response.js +19 -0
- package/dist/task-feed.d.ts +195 -0
- package/dist/task-feed.d.ts.map +1 -0
- package/dist/task-feed.js +530 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
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);
|