@lucascouts/claude-agent-acp-plus 0.33.0 → 0.34.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/README.md +32 -2
- package/dist/acp-agent.d.ts +39 -0
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +352 -16
- package/dist/acp-subagents.d.ts +20 -0
- package/dist/acp-subagents.d.ts.map +1 -0
- package/dist/acp-subagents.js +14 -0
- package/dist/child-session-history.d.ts +26 -0
- package/dist/child-session-history.d.ts.map +1 -0
- package/dist/child-session-history.js +42 -0
- package/dist/child-sessions.d.ts +95 -0
- package/dist/child-sessions.d.ts.map +1 -0
- package/dist/child-sessions.js +247 -0
- package/dist/task-feed.d.ts +3 -0
- package/dist/task-feed.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/acp-agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk/unstable";
|
|
2
|
-
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { deleteSession, getSessionMessages, getSubagentMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
|
|
4
4
|
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
|
|
5
5
|
import { readResumedPermissionMode, readResumedSession, } from "./resumed-session.js";
|
|
@@ -36,6 +36,9 @@ import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./u
|
|
|
36
36
|
import { acceptedPlanToolResult, ExitPlanCoordinator, exitPlanModeRawOutput, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
|
|
37
37
|
import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID, PLAN_UPDATES_CONFIG_ID, } from "./session-config-ids.js";
|
|
38
38
|
import { withPrecompactHistory } from "./compacted-history.js";
|
|
39
|
+
import { subagentForm } from "./acp-subagents.js";
|
|
40
|
+
import { ChildSessionRouter, childStopReasonOf } from "./child-sessions.js";
|
|
41
|
+
import { readSubagentLaunches } from "./child-session-history.js";
|
|
39
42
|
import { findTranscript } from "./transcript-path.js";
|
|
40
43
|
import { parseToolResultMeta } from "./tool-result-meta.js";
|
|
41
44
|
import { AccountUsageTracker } from "./account-usage.js";
|
|
@@ -157,6 +160,9 @@ export const TASKS_OUTPUT_METHOD = "_claude/tasks/output";
|
|
|
157
160
|
export const TASKS_CAPABILITY_KEY = TASKS_META_KEY;
|
|
158
161
|
/** Version 2 (story 041) adds `hasOutput`, `tail` and `_claude/tasks/output`. */
|
|
159
162
|
export const TASKS_CAPABILITY_VERSION = TASKS_CONTRACT_VERSION;
|
|
163
|
+
/** Version 3: a subagent entry names its child session (`childSessionId`).
|
|
164
|
+
* Only a client on the subagents RFD gets it (story 065, R6.2, R6.3). */
|
|
165
|
+
export const TASKS_RFD_CONTRACT_VERSION = 3;
|
|
160
166
|
/** Refresh the account quota windows of one session now, on demand. Params
|
|
161
167
|
* {@link UsageRefreshRequest}, result {@link UsageRefreshResponse}. The windows
|
|
162
168
|
* themselves arrive the way they always do — one `usage_update` per window,
|
|
@@ -400,6 +406,69 @@ function stripSubagentTextAndThinking(content) {
|
|
|
400
406
|
}
|
|
401
407
|
/** A cached tool call's `description` input, when it is a string — or, for a
|
|
402
408
|
* shell call, its `command_description` alias (CLI 2.1.295+, upstream #1330). */
|
|
409
|
+
/** What a child session is announced with, read from `task_started` and the
|
|
410
|
+
* Agent call it names (story 065, R2.1): the Agent call's `name` or the
|
|
411
|
+
* subagent type as the title, its short description, and its prompt. */
|
|
412
|
+
function subagentLaunchOf(parentSessionId, session, message) {
|
|
413
|
+
const input = (session.toolUseCache[message.tool_use_id ?? ""]?.input ?? {});
|
|
414
|
+
const name = typeof input.name === "string" && input.name.trim() ? input.name : undefined;
|
|
415
|
+
const prompt = message.prompt ?? (typeof input.prompt === "string" ? input.prompt : undefined);
|
|
416
|
+
return {
|
|
417
|
+
parentSessionId,
|
|
418
|
+
launcherTaskId: message.parent_task_id,
|
|
419
|
+
toolUseId: message.tool_use_id,
|
|
420
|
+
agentId: message.task_id,
|
|
421
|
+
title: name ?? message.subagent_type ?? message.description,
|
|
422
|
+
description: message.description,
|
|
423
|
+
prompt,
|
|
424
|
+
cancellable: typeof session.query.stopTask === "function",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** A child read back from a transcript, in the shape `ChildSessionRouter`
|
|
428
|
+
* announces: the same title rule as a live launch (`subagentLaunchOf`). */
|
|
429
|
+
function childLaunchOf(parentSessionId, session, toolUseId, record, input) {
|
|
430
|
+
const text = (value) => (typeof value === "string" && value.trim() ? value : undefined);
|
|
431
|
+
const description = record.description ?? text(input.description) ?? "";
|
|
432
|
+
return {
|
|
433
|
+
parentSessionId,
|
|
434
|
+
launcherTaskId: record.launcherAgentId,
|
|
435
|
+
toolUseId,
|
|
436
|
+
agentId: record.agentId,
|
|
437
|
+
title: record.name ?? text(input.name) ?? record.agentType ?? (description || "Subagent"),
|
|
438
|
+
description,
|
|
439
|
+
prompt: text(input.prompt),
|
|
440
|
+
// History, not a task the CLI holds: no Stop until it runs again, when its
|
|
441
|
+
// live launch advertises what the SDK can stop (design.md, Security).
|
|
442
|
+
cancellable: false,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/** The text of a user message whose content is a string or one text block. */
|
|
446
|
+
function userText(content) {
|
|
447
|
+
if (typeof content === "string")
|
|
448
|
+
return content;
|
|
449
|
+
if (Array.isArray(content) && content.length === 1 && content[0]?.type === "text") {
|
|
450
|
+
return content[0].text;
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
/** A child's permission request ended by `session/cancel` on that child. */
|
|
455
|
+
class ChildSessionCancelled extends Error {
|
|
456
|
+
constructor(childId) {
|
|
457
|
+
super(`Subagent ${childId} was cancelled`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** `client` with every `session/update` passed through the child router. */
|
|
461
|
+
function withChildRouting(client, router) {
|
|
462
|
+
return new Proxy(client, {
|
|
463
|
+
get(target, property) {
|
|
464
|
+
if (property === "sessionUpdate") {
|
|
465
|
+
return (notification) => target.sessionUpdate(router.route(notification));
|
|
466
|
+
}
|
|
467
|
+
const value = Reflect.get(target, property, target);
|
|
468
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
403
472
|
function toolDescriptionOf(cache, toolUseId) {
|
|
404
473
|
if (typeof toolUseId !== "string")
|
|
405
474
|
return undefined;
|
|
@@ -772,6 +841,12 @@ export class ClaudeAcpAgent {
|
|
|
772
841
|
sessions;
|
|
773
842
|
client;
|
|
774
843
|
clientCapabilities;
|
|
844
|
+
/** Set at `initialize`: `rfd` when the client declared `clientCapabilities.subagents`. */
|
|
845
|
+
subagentForm;
|
|
846
|
+
/** Present only in the RFD form: moves subagent traffic to child sessions (story 065). */
|
|
847
|
+
childSessions;
|
|
848
|
+
/** Open permission requests of each child, aborted when that child is cancelled. */
|
|
849
|
+
childAsks = new Map();
|
|
775
850
|
logger;
|
|
776
851
|
sessionModes;
|
|
777
852
|
gatewayAuthRequest;
|
|
@@ -843,6 +918,16 @@ export class ClaudeAcpAgent {
|
|
|
843
918
|
}
|
|
844
919
|
async initialize(request) {
|
|
845
920
|
this.clientCapabilities = request.clientCapabilities;
|
|
921
|
+
this.subagentForm = subagentForm(request.clientCapabilities);
|
|
922
|
+
if (this.subagentForm === "rfd" && !this.childSessions) {
|
|
923
|
+
const direct = this.client;
|
|
924
|
+
const router = new ChildSessionRouter((notification) => direct.sessionUpdate(notification), (line) => this.logger.log(line));
|
|
925
|
+
this.childSessions = router;
|
|
926
|
+
// The one place every `session/update` crosses — the consumer's
|
|
927
|
+
// `sendUpdate`, the task feed, hooks, the classifier — so a subagent's
|
|
928
|
+
// update cannot reach the parent by an emission site that forgot it.
|
|
929
|
+
this.client = withChildRouting(direct, router);
|
|
930
|
+
}
|
|
846
931
|
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
|
|
847
932
|
// Only offered when the client advertises `auth._meta.gateway` capability.
|
|
848
933
|
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
|
|
@@ -945,7 +1030,9 @@ export class ClaudeAcpAgent {
|
|
|
945
1030
|
claudeCode: {
|
|
946
1031
|
promptQueueing: true,
|
|
947
1032
|
},
|
|
948
|
-
[TASKS_CAPABILITY_KEY]: {
|
|
1033
|
+
[TASKS_CAPABILITY_KEY]: {
|
|
1034
|
+
version: this.childSessions ? TASKS_RFD_CONTRACT_VERSION : TASKS_CAPABILITY_VERSION,
|
|
1035
|
+
},
|
|
949
1036
|
[USAGE_CAPABILITY_KEY]: { version: USAGE_CAPABILITY_VERSION },
|
|
950
1037
|
},
|
|
951
1038
|
promptCapabilities: {
|
|
@@ -1030,9 +1117,11 @@ export class ClaudeAcpAgent {
|
|
|
1030
1117
|
return response;
|
|
1031
1118
|
}
|
|
1032
1119
|
async resumeSession(params) {
|
|
1120
|
+
this.refuseChildSession(params.sessionId, "lifecycle");
|
|
1033
1121
|
if (this.providerUpdate)
|
|
1034
1122
|
await this.providerUpdate;
|
|
1035
1123
|
const result = await this.getOrCreateSession(params);
|
|
1124
|
+
await this.restoreChildSessions(params.sessionId);
|
|
1036
1125
|
// Needs to happen after we return the session
|
|
1037
1126
|
setTimeout(() => {
|
|
1038
1127
|
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
@@ -1040,6 +1129,7 @@ export class ClaudeAcpAgent {
|
|
|
1040
1129
|
return result;
|
|
1041
1130
|
}
|
|
1042
1131
|
async loadSession(params) {
|
|
1132
|
+
this.refuseChildSession(params.sessionId, "lifecycle");
|
|
1043
1133
|
if (this.providerUpdate)
|
|
1044
1134
|
await this.providerUpdate;
|
|
1045
1135
|
const result = await this.getOrCreateSession(params);
|
|
@@ -1673,6 +1763,37 @@ export class ClaudeAcpAgent {
|
|
|
1673
1763
|
* `session/prompt`. Without the opt-in, the existing detached `prompt()` and
|
|
1674
1764
|
* `startedNewTurn` result are preserved for compatibility. */
|
|
1675
1765
|
/** `_claude/tasks/stop`: stop a task the feed holds as running (R2.1, R2.2). */
|
|
1766
|
+
/** `_claude/tasks` v3: each subagent entry with a child session names it
|
|
1767
|
+
* (R6.1); every other entry, and every snapshot off the RFD, is the v2
|
|
1768
|
+
* object as is (R6.3, R6.4). */
|
|
1769
|
+
withChildSessionIds(snapshot) {
|
|
1770
|
+
const router = this.childSessions;
|
|
1771
|
+
if (!router)
|
|
1772
|
+
return snapshot;
|
|
1773
|
+
return {
|
|
1774
|
+
...snapshot,
|
|
1775
|
+
tasks: snapshot.tasks.map((task) => task.type === "subagent" && router.isChild(task.id)
|
|
1776
|
+
? { ...task, childSessionId: task.id }
|
|
1777
|
+
: task),
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
/** A child session is the parent's: its config is read-only, and it is
|
|
1781
|
+
* loaded, resumed and closed with its parent (story 065, R5.1, R5.2). Checked
|
|
1782
|
+
* first, so an id that names no session keeps the error it has today. */
|
|
1783
|
+
refuseChildSession(sessionId, operation) {
|
|
1784
|
+
if (!this.childSessions?.isChild(sessionId))
|
|
1785
|
+
return;
|
|
1786
|
+
throw RequestError.invalidParams({ sessionId }, operation === "config"
|
|
1787
|
+
? "subagent sessions are read-only and loaded with their parent"
|
|
1788
|
+
: "subagent sessions are loaded with their parent and cannot be loaded, resumed or closed alone");
|
|
1789
|
+
}
|
|
1790
|
+
/** A subagent task ended: its child, if it has one, goes idle (story 065, R2.4). */
|
|
1791
|
+
async settleChildSession(taskId, status) {
|
|
1792
|
+
const stopReason = childStopReasonOf(status);
|
|
1793
|
+
if (!stopReason || !this.childSessions?.isChild(taskId))
|
|
1794
|
+
return;
|
|
1795
|
+
await this.childSessions.settle(taskId, stopReason);
|
|
1796
|
+
}
|
|
1676
1797
|
async stopTask(params) {
|
|
1677
1798
|
const { sessionId, taskId } = params;
|
|
1678
1799
|
return this.taskAction(TASKS_STOP_METHOD, sessionId, `taskId=${logValue(taskId)}`, async (session) => {
|
|
@@ -3413,6 +3534,9 @@ export class ClaudeAcpAgent {
|
|
|
3413
3534
|
if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
|
|
3414
3535
|
(session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
|
|
3415
3536
|
}
|
|
3537
|
+
if (this.childSessions && message.subagent_type && message.tool_use_id) {
|
|
3538
|
+
await this.childSessions.launch(subagentLaunchOf(params.sessionId, session, message));
|
|
3539
|
+
}
|
|
3416
3540
|
// The spawning tool call's own `description` labels a shell
|
|
3417
3541
|
// task instead of its command line. Read it now: the cache
|
|
3418
3542
|
// entry is pruned at `tool_result`, which for a background
|
|
@@ -3427,6 +3551,7 @@ export class ClaudeAcpAgent {
|
|
|
3427
3551
|
// The task settled — no further tool calls can originate
|
|
3428
3552
|
// from it, so its registry entry can be dropped.
|
|
3429
3553
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
3554
|
+
await this.settleChildSession(message.task_id, message.status);
|
|
3430
3555
|
await endBackgroundTerminal(message.task_id, backgroundExitCode(message.status, message.summary));
|
|
3431
3556
|
armDrainGrace();
|
|
3432
3557
|
if (session.taskFeed && session.taskFeedPublisher) {
|
|
@@ -3452,6 +3577,7 @@ export class ClaudeAcpAgent {
|
|
|
3452
3577
|
message.patch.status === "failed" ||
|
|
3453
3578
|
message.patch.status === "killed") {
|
|
3454
3579
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
3580
|
+
await this.settleChildSession(message.task_id, message.patch.status);
|
|
3455
3581
|
// Usually first: the notification with its summary follows
|
|
3456
3582
|
// but is not guaranteed, so the terminal ends here.
|
|
3457
3583
|
await endBackgroundTerminal(message.task_id, backgroundExitCode(message.patch.status, message.patch.error));
|
|
@@ -4389,7 +4515,11 @@ export class ClaudeAcpAgent {
|
|
|
4389
4515
|
break;
|
|
4390
4516
|
}
|
|
4391
4517
|
}
|
|
4392
|
-
|
|
4518
|
+
// A parent cancel keeps its background subagents running (story
|
|
4519
|
+
// 065, R3.2): in the RFD form their stream goes on to their child
|
|
4520
|
+
// sessions even while the parent's cancelled turn drains.
|
|
4521
|
+
if (session.cancelled &&
|
|
4522
|
+
!this.childSessions?.childFor(params.sessionId, parentToolUseIdOf(message))) {
|
|
4393
4523
|
break;
|
|
4394
4524
|
}
|
|
4395
4525
|
// Synthetic assistant frames carry the CLI's local-command output.
|
|
@@ -4592,7 +4722,11 @@ export class ClaudeAcpAgent {
|
|
|
4592
4722
|
streamedBlocks.length = 0;
|
|
4593
4723
|
}
|
|
4594
4724
|
else if (message.type === "assistant" &&
|
|
4595
|
-
!(session.forwardSubagentText ||
|
|
4725
|
+
!(session.forwardSubagentText ||
|
|
4726
|
+
supportsSubagentTranscript(this.clientCapabilities) ||
|
|
4727
|
+
// A child session is the subagent's own transcript (story 065):
|
|
4728
|
+
// its text belongs there, never in the parent's feed.
|
|
4729
|
+
this.childSessions?.childFor(params.sessionId, message.parent_tool_use_id))) {
|
|
4596
4730
|
// Legacy clients don't understand nested transcripts. Keep the
|
|
4597
4731
|
// historical behavior for them: subagent text/thinking remains
|
|
4598
4732
|
// internal to the tool call instead of leaking into the top-level
|
|
@@ -4844,8 +4978,59 @@ export class ClaudeAcpAgent {
|
|
|
4844
4978
|
}
|
|
4845
4979
|
}
|
|
4846
4980
|
async cancel(params) {
|
|
4981
|
+
if (this.childSessions?.isChild(params.sessionId)) {
|
|
4982
|
+
await this.cancelChildSession(params.sessionId);
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4847
4985
|
await this.cancelTurns(params, { awaitInterrupt: true });
|
|
4848
4986
|
}
|
|
4987
|
+
/** Register an ask (permission or question) of `childId`, aborted when that
|
|
4988
|
+
* child is cancelled; already aborted when it was. No-op for the parent. */
|
|
4989
|
+
openChildAsk(childId) {
|
|
4990
|
+
const controller = new AbortController();
|
|
4991
|
+
if (!childId)
|
|
4992
|
+
return controller;
|
|
4993
|
+
if (this.childSessions.isCancelled(childId)) {
|
|
4994
|
+
controller.abort();
|
|
4995
|
+
return controller;
|
|
4996
|
+
}
|
|
4997
|
+
let asks = this.childAsks.get(childId);
|
|
4998
|
+
if (!asks)
|
|
4999
|
+
this.childAsks.set(childId, (asks = new Set()));
|
|
5000
|
+
asks.add(controller);
|
|
5001
|
+
return controller;
|
|
5002
|
+
}
|
|
5003
|
+
closeChildAsk(childId, controller) {
|
|
5004
|
+
if (childId)
|
|
5005
|
+
this.childAsks.get(childId)?.delete(controller);
|
|
5006
|
+
}
|
|
5007
|
+
/** `session/cancel` naming a child stops that subagent alone: its SDK task,
|
|
5008
|
+
* never the parent's query, and its open questions are answered denied
|
|
5009
|
+
* (story 065, R3.1, R3.3). On a failed stop the state stays as reported. */
|
|
5010
|
+
async cancelChildSession(childId) {
|
|
5011
|
+
const parentId = this.childSessions.parentOf(childId);
|
|
5012
|
+
const session = this.sessions[parentId];
|
|
5013
|
+
if (!session) {
|
|
5014
|
+
// The parent is gone, and its query with it: nothing is left to stop.
|
|
5015
|
+
this.logger.log(`[subagents/cancel] outcome=parent_gone sessionId=${logValue(parentId)} childSessionId=${logValue(childId)}`);
|
|
5016
|
+
await this.childSessions.settle(childId, "cancelled");
|
|
5017
|
+
return;
|
|
5018
|
+
}
|
|
5019
|
+
// Join a query recreate, as `cancelTurns` does, so the stop reaches the live query.
|
|
5020
|
+
if (session.queryRecreateInFlight)
|
|
5021
|
+
await session.queryRecreateInFlight;
|
|
5022
|
+
try {
|
|
5023
|
+
await session.query.stopTask(childId);
|
|
5024
|
+
}
|
|
5025
|
+
catch (error) {
|
|
5026
|
+
this.logger.log(`[subagents/cancel] outcome=failed level=warn sessionId=${logValue(parentId)} childSessionId=${logValue(childId)} reason=${logValue(errorMessage(error))}`);
|
|
5027
|
+
throw new Error(`Could not stop subagent ${childId}`, { cause: error });
|
|
5028
|
+
}
|
|
5029
|
+
for (const ask of this.childAsks.get(childId) ?? [])
|
|
5030
|
+
ask.abort();
|
|
5031
|
+
this.childAsks.delete(childId);
|
|
5032
|
+
await this.childSessions.settle(childId, "cancelled");
|
|
5033
|
+
}
|
|
4849
5034
|
/** Cancel the session's turns and interrupt the SDK query. With
|
|
4850
5035
|
* `awaitInterrupt: false` the interrupt is sent, but its reply is not
|
|
4851
5036
|
* awaited. `teardownSession` uses that: it closes the query right after, and
|
|
@@ -5157,7 +5342,10 @@ export class ClaudeAcpAgent {
|
|
|
5157
5342
|
{ warn: (message) => this.logger.log(`${message} level=warn`) }, sessionId);
|
|
5158
5343
|
const taskFeedPublisher = new TaskFeedPublisher(taskFeed, (snapshot) => this.client.sessionUpdate({
|
|
5159
5344
|
sessionId,
|
|
5160
|
-
update: {
|
|
5345
|
+
update: {
|
|
5346
|
+
sessionUpdate: "session_info_update",
|
|
5347
|
+
_meta: { [TASKS_META_KEY]: this.withChildSessionIds(snapshot) },
|
|
5348
|
+
},
|
|
5161
5349
|
}), undefined, (error) => this.logger.error(`[tasks/feed] sessionId=${logValue(sessionId)} outcome=send_failed reason=${logValue(errorMessage(error))}`));
|
|
5162
5350
|
const tailPoller = new TailPoller(taskFeed, taskFeedPublisher, undefined, undefined, (line) => this.logger.log(line), sessionId);
|
|
5163
5351
|
return { taskFeed, taskFeedPublisher, tailPoller };
|
|
@@ -5200,6 +5388,7 @@ export class ClaudeAcpAgent {
|
|
|
5200
5388
|
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
|
5201
5389
|
}
|
|
5202
5390
|
async closeSession(params) {
|
|
5391
|
+
this.refuseChildSession(params.sessionId, "lifecycle");
|
|
5203
5392
|
if (!this.sessions[params.sessionId]) {
|
|
5204
5393
|
throw new Error("Session not found");
|
|
5205
5394
|
}
|
|
@@ -5218,6 +5407,7 @@ export class ClaudeAcpAgent {
|
|
|
5218
5407
|
return {};
|
|
5219
5408
|
}
|
|
5220
5409
|
async setSessionMode(params) {
|
|
5410
|
+
this.refuseChildSession(params.sessionId, "config");
|
|
5221
5411
|
// A lazy Thinking recreate may be swapping the session's query right now;
|
|
5222
5412
|
// join it (mirroring prompt()) so `setPermissionMode` below lands on the
|
|
5223
5413
|
// replacement query after cutover instead of an about-to-close one — and
|
|
@@ -5233,6 +5423,7 @@ export class ClaudeAcpAgent {
|
|
|
5233
5423
|
return this.sessionModes.setSessionMode(params);
|
|
5234
5424
|
}
|
|
5235
5425
|
async setSessionConfigOption(params) {
|
|
5426
|
+
this.refuseChildSession(params.sessionId, "config");
|
|
5236
5427
|
const session = this.sessions[params.sessionId];
|
|
5237
5428
|
if (!session) {
|
|
5238
5429
|
throw new Error("Session not found");
|
|
@@ -5375,8 +5566,99 @@ export class ClaudeAcpAgent {
|
|
|
5375
5566
|
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
|
5376
5567
|
return { configOptions: session.configOptions };
|
|
5377
5568
|
}
|
|
5569
|
+
/** A resumed parent's children, registered under their transcript ids but
|
|
5570
|
+
* not announced: a resume replays nothing, and a child the parent
|
|
5571
|
+
* continues is announced when it runs (story 065, R4.3). */
|
|
5572
|
+
async restoreChildSessions(sessionId) {
|
|
5573
|
+
if (!this.childSessions)
|
|
5574
|
+
return;
|
|
5575
|
+
const session = this.sessions[sessionId];
|
|
5576
|
+
const launches = await readSubagentLaunches(CLAUDE_CONFIG_DIR, sessionId);
|
|
5577
|
+
// Launchers before the subagents they launched, so each nests under its own.
|
|
5578
|
+
const byDepth = [...launches].sort(([, a], [, b]) => a.depth - b.depth);
|
|
5579
|
+
for (const [toolUseId, record] of byDepth) {
|
|
5580
|
+
await this.childSessions.restore(childLaunchOf(sessionId, session, toolUseId, record, {}), {
|
|
5581
|
+
announce: false,
|
|
5582
|
+
});
|
|
5583
|
+
}
|
|
5584
|
+
}
|
|
5585
|
+
/** Replay one child of a loaded parent right after the Agent call that
|
|
5586
|
+
* launched it: its announcement and prompt on the parent, then its own
|
|
5587
|
+
* transcript on its session (story 065, R4.1, R4.2). */
|
|
5588
|
+
async replayChildSession(parentId, toolUseId, input, record, launches, replayed) {
|
|
5589
|
+
// Each child once per load: a damaged meta naming a launch inside the
|
|
5590
|
+
// child's own transcript would otherwise recurse without end.
|
|
5591
|
+
if (replayed.has(record.agentId))
|
|
5592
|
+
return;
|
|
5593
|
+
replayed.add(record.agentId);
|
|
5594
|
+
const launch = childLaunchOf(parentId, this.sessions[parentId], toolUseId, record, input);
|
|
5595
|
+
await this.childSessions.restore(launch, { announce: true });
|
|
5596
|
+
if (this.childSessions.parentOf(record.agentId) !== parentId)
|
|
5597
|
+
return;
|
|
5598
|
+
let messages = [];
|
|
5599
|
+
let reason = "empty";
|
|
5600
|
+
try {
|
|
5601
|
+
messages = await getSubagentMessages(parentId, record.agentId);
|
|
5602
|
+
}
|
|
5603
|
+
catch (error) {
|
|
5604
|
+
reason = errorMessage(error);
|
|
5605
|
+
}
|
|
5606
|
+
if (messages.length === 0) {
|
|
5607
|
+
// The SDK answers a missing transcript with []: only the association replays.
|
|
5608
|
+
this.logger.log(`[subagents/replay] outcome=no_transcript level=warn sessionId=${logValue(parentId)} childSessionId=${logValue(record.agentId)} reason=${logValue(reason)}`);
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
const toolUseCache = {};
|
|
5612
|
+
let promptSkipped = false;
|
|
5613
|
+
for (const message of messages) {
|
|
5614
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
5615
|
+
const role = message.message?.role;
|
|
5616
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
5617
|
+
let content = message.message?.content;
|
|
5618
|
+
if (role === "user") {
|
|
5619
|
+
// The launch prompt already went out as the parent's session_message.
|
|
5620
|
+
if (!promptSkipped && launch.prompt && userText(content) === launch.prompt) {
|
|
5621
|
+
promptSkipped = true;
|
|
5622
|
+
continue;
|
|
5623
|
+
}
|
|
5624
|
+
content = stripLocalCommandMetadata(content);
|
|
5625
|
+
if (content === null)
|
|
5626
|
+
continue;
|
|
5627
|
+
}
|
|
5628
|
+
for (const notification of toAcpNotifications(
|
|
5629
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
5630
|
+
content, role, parentId, toolUseCache, this.client, this.logger, {
|
|
5631
|
+
registerHooks: false,
|
|
5632
|
+
clientCapabilities: this.clientCapabilities,
|
|
5633
|
+
cwd: this.sessions[parentId]?.cwd,
|
|
5634
|
+
taskState: this.sessions[parentId]?.taskState,
|
|
5635
|
+
messageId: messageIdForGrouping(message),
|
|
5636
|
+
})) {
|
|
5637
|
+
await this.client.sessionUpdate({ ...notification, sessionId: record.agentId });
|
|
5638
|
+
}
|
|
5639
|
+
// A nested subagent's launch lives in its launcher's transcript.
|
|
5640
|
+
if (role === "assistant") {
|
|
5641
|
+
await this.replayLaunchesIn(parentId, content, launches, replayed);
|
|
5642
|
+
}
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
/** Replay the children launched by the Agent calls in `content`. */
|
|
5646
|
+
async replayLaunchesIn(parentId, content, launches, replayed) {
|
|
5647
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
5648
|
+
const record = block?.type === "tool_use" && typeof block.id === "string"
|
|
5649
|
+
? launches.get(block.id)
|
|
5650
|
+
: undefined;
|
|
5651
|
+
if (record) {
|
|
5652
|
+
await this.replayChildSession(parentId, block.id, block.input ?? {}, record, launches, replayed);
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
}
|
|
5378
5656
|
async replaySessionHistory(sessionId) {
|
|
5379
5657
|
const toolUseCache = {};
|
|
5658
|
+
const childLaunches = this.childSessions
|
|
5659
|
+
? await readSubagentLaunches(CLAUDE_CONFIG_DIR, sessionId)
|
|
5660
|
+
: undefined;
|
|
5661
|
+
const replayedChildren = new Set();
|
|
5380
5662
|
// getSessionMessages stops at the last compaction boundary; put the history
|
|
5381
5663
|
// before it back, so a reopened compacted thread scrolls to its first prompt.
|
|
5382
5664
|
const messages = await withPrecompactHistory(sessionId, await getSessionMessages(sessionId), CLAUDE_CONFIG_DIR);
|
|
@@ -5519,6 +5801,9 @@ export class ClaudeAcpAgent {
|
|
|
5519
5801
|
})) {
|
|
5520
5802
|
await this.client.sessionUpdate(notification);
|
|
5521
5803
|
}
|
|
5804
|
+
if (childLaunches?.size && message.type === "assistant" && parentToolUseId === null) {
|
|
5805
|
+
await this.replayLaunchesIn(sessionId, content, childLaunches, replayedChildren);
|
|
5806
|
+
}
|
|
5522
5807
|
}
|
|
5523
5808
|
}
|
|
5524
5809
|
async readTextFile(params) {
|
|
@@ -5570,21 +5855,44 @@ export class ClaudeAcpAgent {
|
|
|
5570
5855
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
5571
5856
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
5572
5857
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
5858
|
+
// A child's ask is registered before anything awaits, so a cancel of
|
|
5859
|
+
// that child landing meanwhile still answers it (story 065, R3.3).
|
|
5860
|
+
const childId = this.childSessions?.childFor(params.sessionId, parentToolUseId);
|
|
5861
|
+
const childCancel = this.openChildAsk(childId);
|
|
5573
5862
|
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
|
|
5574
|
-
if (signal.aborted)
|
|
5863
|
+
if (signal.aborted) {
|
|
5864
|
+
this.closeChildAsk(childId, childCancel);
|
|
5575
5865
|
throw new Error("Tool use aborted");
|
|
5866
|
+
}
|
|
5576
5867
|
// Do not rely on every ACP client settling requestPermission after the
|
|
5577
5868
|
// cancellation signal. The local race guarantees that Claude's tool call
|
|
5578
5869
|
// is released even when an older or broken client ignores $/cancel_request.
|
|
5870
|
+
const outgoing = childId ? { ...params, sessionId: childId } : params;
|
|
5871
|
+
const requestSignal = childId ? AbortSignal.any([signal, childCancel.signal]) : signal;
|
|
5872
|
+
if (childId) {
|
|
5873
|
+
if (childCancel.signal.aborted) {
|
|
5874
|
+
this.closeChildAsk(childId, childCancel);
|
|
5875
|
+
throw new ChildSessionCancelled(childId);
|
|
5876
|
+
}
|
|
5877
|
+
await this.childSessions.askOpened(childId);
|
|
5878
|
+
}
|
|
5579
5879
|
try {
|
|
5580
|
-
return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(
|
|
5880
|
+
return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(outgoing, requestSignal), requestSignal));
|
|
5581
5881
|
}
|
|
5582
5882
|
catch (error) {
|
|
5583
5883
|
if (signal.aborted) {
|
|
5584
5884
|
throw new Error("Tool use aborted", { cause: error });
|
|
5585
5885
|
}
|
|
5886
|
+
if (childCancel.signal.aborted)
|
|
5887
|
+
throw new ChildSessionCancelled(childId);
|
|
5586
5888
|
throw error;
|
|
5587
5889
|
}
|
|
5890
|
+
finally {
|
|
5891
|
+
if (childId) {
|
|
5892
|
+
this.closeChildAsk(childId, childCancel);
|
|
5893
|
+
await this.childSessions.askClosed(childId);
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5588
5896
|
}
|
|
5589
5897
|
/** Emit the `tool_call` a permission request references if it hasn't been sent
|
|
5590
5898
|
* yet, so the client has the tool call before being asked to approve it. The
|
|
@@ -5685,7 +5993,7 @@ export class ClaudeAcpAgent {
|
|
|
5685
5993
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
5686
5994
|
// make sure the tool_call has surfaced to the client before we send it.
|
|
5687
5995
|
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
|
|
5688
|
-
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
5996
|
+
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal, this.childSessions?.childFor(sessionId, parentToolUseId));
|
|
5689
5997
|
}
|
|
5690
5998
|
// Do not auto-allow here based on the session's advertised mode. Claude
|
|
5691
5999
|
// Code applies bypassPermissions before invoking canUseTool; a request
|
|
@@ -5732,11 +6040,20 @@ export class ClaudeAcpAgent {
|
|
|
5732
6040
|
? undefined
|
|
5733
6041
|
: Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
|
|
5734
6042
|
});
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
6043
|
+
let response;
|
|
6044
|
+
try {
|
|
6045
|
+
response = await this.requestPermissionFromClient({
|
|
6046
|
+
...presentation,
|
|
6047
|
+
options: permissionOptions,
|
|
6048
|
+
sessionId,
|
|
6049
|
+
}, toolName, signal, parentToolUseId);
|
|
6050
|
+
}
|
|
6051
|
+
catch (error) {
|
|
6052
|
+
if (error instanceof ChildSessionCancelled) {
|
|
6053
|
+
return { behavior: "deny", message: "The subagent was cancelled." };
|
|
6054
|
+
}
|
|
6055
|
+
throw error;
|
|
6056
|
+
}
|
|
5740
6057
|
if (signal.aborted)
|
|
5741
6058
|
throw new Error("Tool use aborted");
|
|
5742
6059
|
const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
|
|
@@ -5822,17 +6139,31 @@ export class ClaudeAcpAgent {
|
|
|
5822
6139
|
* elicitation and return the answers as the tool's `updatedInput`. Called from
|
|
5823
6140
|
* `canUseTool` since that is where the SDK routes the tool's permission check.
|
|
5824
6141
|
*/
|
|
5825
|
-
async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal
|
|
6142
|
+
async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal,
|
|
6143
|
+
/** A subagent's question goes out on its child session (story 065, R2.3). */
|
|
6144
|
+
childId) {
|
|
5826
6145
|
const questions = extractAskUserQuestions(toolInput);
|
|
5827
6146
|
if (!questions) {
|
|
5828
6147
|
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
|
5829
6148
|
}
|
|
5830
|
-
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
6149
|
+
const createRequest = askUserQuestionsToCreateRequest(questions, childId ?? sessionId, toolUseID);
|
|
5831
6150
|
let response;
|
|
6151
|
+
const childCancel = this.openChildAsk(childId);
|
|
6152
|
+
if (childCancel.signal.aborted) {
|
|
6153
|
+
return { behavior: "deny", message: "The subagent was cancelled." };
|
|
6154
|
+
}
|
|
6155
|
+
const requestSignal = childId ? AbortSignal.any([signal, childCancel.signal]) : signal;
|
|
6156
|
+
if (childId)
|
|
6157
|
+
await this.childSessions?.askOpened(childId);
|
|
5832
6158
|
try {
|
|
5833
|
-
response = await this.withPendingUserInput(sessionId, () =>
|
|
6159
|
+
response = await this.withPendingUserInput(sessionId, () => childId
|
|
6160
|
+
? raceWithAbort(this.client.unstable_createElicitation(createRequest, requestSignal), requestSignal)
|
|
6161
|
+
: this.client.unstable_createElicitation(createRequest, signal));
|
|
5834
6162
|
}
|
|
5835
6163
|
catch (error) {
|
|
6164
|
+
if (childCancel.signal.aborted && !signal.aborted) {
|
|
6165
|
+
return { behavior: "deny", message: "The subagent was cancelled." };
|
|
6166
|
+
}
|
|
5836
6167
|
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
5837
6168
|
// use, matching the post-response check below.
|
|
5838
6169
|
if (signal.aborted) {
|
|
@@ -5841,6 +6172,11 @@ export class ClaudeAcpAgent {
|
|
|
5841
6172
|
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
|
5842
6173
|
return { behavior: "deny", message: "Could not present the question to the user." };
|
|
5843
6174
|
}
|
|
6175
|
+
finally {
|
|
6176
|
+
this.closeChildAsk(childId, childCancel);
|
|
6177
|
+
if (childId)
|
|
6178
|
+
await this.childSessions?.askClosed(childId);
|
|
6179
|
+
}
|
|
5844
6180
|
if (signal.aborted) {
|
|
5845
6181
|
throw new Error("Tool use aborted");
|
|
5846
6182
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
|
2
|
+
/**
|
|
3
|
+
* How a client gets native subagent sessions.
|
|
4
|
+
*
|
|
5
|
+
* `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
|
6
|
+
* as revised) — `subagent_update` and `session_message`, which the SDK types.
|
|
7
|
+
* Ported from upstream `src/acp-subagents.ts` without its `air` form: AIR's
|
|
8
|
+
* earlier draft of the RFD has no client in this chain (story 065, design.md).
|
|
9
|
+
*/
|
|
10
|
+
export type SubagentForm = "rfd";
|
|
11
|
+
/** The form of native subagent sessions that the client gets, or none. */
|
|
12
|
+
export declare function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* The schema's `SubagentCapabilities` is an object (`{}` or `{ _meta }`); any
|
|
15
|
+
* other shape is not a declaration. Zed drops `subagent_update` and
|
|
16
|
+
* `session_message` from a client that did not advertise it, so a false yes
|
|
17
|
+
* here loses the subagents entirely.
|
|
18
|
+
*/
|
|
19
|
+
export declare function clientSupportsSubagents(capabilities?: ClientCapabilities | null): boolean;
|
|
20
|
+
//# sourceMappingURL=acp-subagents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acp-subagents.d.ts","sourceRoot":"","sources":["../src/acp-subagents.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AAE5E;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,KAAK,CAAC;AAEjC,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,YAAY,CAAC,EAAE,kBAAkB,GAAG,IAAI,GAAG,YAAY,GAAG,SAAS,CAE/F;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,CAAC,EAAE,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAGzF"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The form of native subagent sessions that the client gets, or none. */
|
|
2
|
+
export function subagentForm(capabilities) {
|
|
3
|
+
return clientSupportsSubagents(capabilities) ? "rfd" : undefined;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The schema's `SubagentCapabilities` is an object (`{}` or `{ _meta }`); any
|
|
7
|
+
* other shape is not a declaration. Zed drops `subagent_update` and
|
|
8
|
+
* `session_message` from a client that did not advertise it, so a false yes
|
|
9
|
+
* here loses the subagents entirely.
|
|
10
|
+
*/
|
|
11
|
+
export function clientSupportsSubagents(capabilities) {
|
|
12
|
+
const subagents = capabilities?.subagents;
|
|
13
|
+
return typeof subagents === "object" && subagents !== null && !Array.isArray(subagents);
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story 065, Task 4.1 — which Agent call launched which subagent, read back
|
|
3
|
+
* from a session's transcripts so a replayed or restored child keeps the id it
|
|
4
|
+
* had live (R4.1, R4.3).
|
|
5
|
+
*
|
|
6
|
+
* The bundled CLI writes, beside each subagent transcript
|
|
7
|
+
* `<session>/subagents/agent-<agentId>.jsonl`, a metadata file
|
|
8
|
+
* `agent-<agentId>.meta.json` whose `toolUseId` names the launching call
|
|
9
|
+
* (the same layout upstream's `subagent-history.ts` reads). The child id is
|
|
10
|
+
* the `<agentId>` of the file name — never generated: a launch without
|
|
11
|
+
* metadata has no known id and is left nested, as before.
|
|
12
|
+
*/
|
|
13
|
+
export type SubagentLaunchRecord = {
|
|
14
|
+
agentId: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
agentType?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** `parentAgentId`: the subagent whose agent launched this one (a nested
|
|
19
|
+
* subagent, whose launch is in that agent's transcript, not the parent's). */
|
|
20
|
+
launcherAgentId?: string;
|
|
21
|
+
/** `spawnDepth`: 1 for a launch by the main thread. */
|
|
22
|
+
depth: number;
|
|
23
|
+
};
|
|
24
|
+
/** The subagents of `sessionId`, keyed by the tool use id that launched each. */
|
|
25
|
+
export declare function readSubagentLaunches(configDir: string, sessionId: string): Promise<Map<string, SubagentLaunchRecord>>;
|
|
26
|
+
//# sourceMappingURL=child-session-history.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"child-session-history.d.ts","sourceRoot":"","sources":["../src/child-session-history.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;mFAC+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAIF,iFAAiF;AACjF,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAoC5C"}
|