@agentclientprotocol/claude-agent-acp 0.45.1 → 0.47.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.d.ts +83 -16
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +483 -217
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +40 -4
- package/package.json +6 -6
package/dist/acp-agent.d.ts
CHANGED
|
@@ -31,10 +31,54 @@ type SessionModelState = {
|
|
|
31
31
|
}>;
|
|
32
32
|
currentModelId: string;
|
|
33
33
|
};
|
|
34
|
+
/** One in-flight `prompt()` call. A persistent per-session consumer (see
|
|
35
|
+
* `runConsumer`) drains the SDK query stream for the whole session and settles
|
|
36
|
+
* each Turn's deferred when that turn's outcome is known, so `prompt()` itself
|
|
37
|
+
* holds no loop. Turns are processed FIFO: the SDK echoes queued user messages
|
|
38
|
+
* back in submission order, so `turnQueue[0]` is the turn currently running. */
|
|
39
|
+
type Turn = {
|
|
40
|
+
/** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so the
|
|
41
|
+
* consumer can match the replayed user message to this turn. */
|
|
42
|
+
promptUuid: string;
|
|
43
|
+
/** Local-only slash commands (e.g. `/clear`) return a result without an echo,
|
|
44
|
+
* so the consumer can't promote them via the replay; it falls back to
|
|
45
|
+
* promoting the queue head when the result arrives. */
|
|
46
|
+
isLocalOnlyCommand: boolean;
|
|
47
|
+
/** Set once the deferred has been resolved/rejected, so the consumer never
|
|
48
|
+
* settles a turn twice (idle + handoff + stream-end can all race). */
|
|
49
|
+
settled: boolean;
|
|
50
|
+
resolve: (response: PromptResponse) => void;
|
|
51
|
+
reject: (error: unknown) => void;
|
|
52
|
+
};
|
|
34
53
|
type Session = {
|
|
35
54
|
query: Query;
|
|
36
55
|
input: Pushable<SDKUserMessage>;
|
|
37
56
|
cancelled: boolean;
|
|
57
|
+
/** FIFO of in-flight prompts. The head is the turn the SDK is currently
|
|
58
|
+
* processing; later entries are queued and will be echoed in order. */
|
|
59
|
+
turnQueue?: Turn[];
|
|
60
|
+
/** The turn whose messages the consumer is currently attributing output to
|
|
61
|
+
* (the head of `turnQueue` once its user message has been echoed). */
|
|
62
|
+
activeTurn?: Turn | null;
|
|
63
|
+
/** Count of result messages the consumer should treat as orphans and skip
|
|
64
|
+
* (not promote/attribute to the current head). When cancel() settles+removes
|
|
65
|
+
* a queued turn, that turn's user message was already pushed to the SDK, so
|
|
66
|
+
* the SDK still runs it and emits a result with no uuid we can match. Because
|
|
67
|
+
* the SDK processes input FIFO, those orphan results arrive (in submission
|
|
68
|
+
* order) before the next live turn's, so skipping exactly this many leaves
|
|
69
|
+
* the genuine head untouched. Reset to 0 on every activation as a backstop
|
|
70
|
+
* against an SDK that drops queued input on interrupt (no orphan emitted). */
|
|
71
|
+
pendingOrphanResults?: number;
|
|
72
|
+
/** The long-lived consumer task. Lazily started on the first `prompt()` and
|
|
73
|
+
* kept alive for the session so between-turn/background messages are still
|
|
74
|
+
* drained and forwarded. */
|
|
75
|
+
consumer?: Promise<void>;
|
|
76
|
+
/** Set once the SDK query stream has terminated (it ran to `done` or threw a
|
|
77
|
+
* non-process error). The query iterator is not reusable afterward, so a
|
|
78
|
+
* later `prompt()` rejects instead of enqueueing onto a dead stream and
|
|
79
|
+
* hanging (or silently restarting a consumer that resolves `end_turn`
|
|
80
|
+
* without ever reaching the model). */
|
|
81
|
+
queryClosed?: boolean;
|
|
38
82
|
cwd: string;
|
|
39
83
|
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
|
40
84
|
* detect when loadSession/resumeSession is called with changed values. */
|
|
@@ -45,22 +89,16 @@ type Session = {
|
|
|
45
89
|
models: SessionModelState;
|
|
46
90
|
modelInfos: ModelInfo[];
|
|
47
91
|
configOptions: SessionConfigOption[];
|
|
48
|
-
promptRunning: boolean;
|
|
49
|
-
pendingMessages: Map<string, {
|
|
50
|
-
resolve: (cancelled: boolean) => void;
|
|
51
|
-
order: number;
|
|
52
|
-
}>;
|
|
53
|
-
nextPendingOrder: number;
|
|
54
92
|
abortController: AbortController;
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
93
|
+
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
|
94
|
+
* (after a grace period) to force the active turn to settle "cancelled" when
|
|
95
|
+
* the SDK is wedged and `query.next()` never yields again (issue #680).
|
|
96
|
+
* Distinct from `abortController`: this only wakes the consumer; it does NOT
|
|
97
|
+
* touch the SDK query/subprocess. The consumer re-arms it after each fire.
|
|
98
|
+
* Undefined until the consumer is started by the first prompt. */
|
|
61
99
|
cancelController?: AbortController;
|
|
62
|
-
/** Pending grace-period timer that aborts `cancelController`. Cleared when
|
|
63
|
-
*
|
|
100
|
+
/** Pending grace-period timer that aborts `cancelController`. Cleared when the
|
|
101
|
+
* active turn settles normally so the backstop never fires after a clean
|
|
64
102
|
* cancel. */
|
|
65
103
|
forceCancelTimer?: ReturnType<typeof setTimeout>;
|
|
66
104
|
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
|
@@ -220,9 +258,38 @@ export declare class ClaudeAcpAgent implements Agent {
|
|
|
220
258
|
listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse>;
|
|
221
259
|
authenticate(_params: AuthenticateRequest): Promise<void>;
|
|
222
260
|
prompt(params: PromptRequest): Promise<PromptResponse>;
|
|
261
|
+
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
262
|
+
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
263
|
+
private ensureConsumer;
|
|
264
|
+
/** The single, long-lived consumer of the SDK query stream for a session. It
|
|
265
|
+
* forwards every message as ACP `sessionUpdate`s (so background/between-turn
|
|
266
|
+
* output streams live, not just while a prompt is awaiting) and settles each
|
|
267
|
+
* Turn's deferred when that turn ends. Replaces the per-prompt message loop;
|
|
268
|
+
* `params` only carries the (session-invariant) `sessionId`. */
|
|
269
|
+
private runConsumer;
|
|
223
270
|
cancel(params: CancelNotification): Promise<void>;
|
|
224
|
-
/**
|
|
225
|
-
*
|
|
271
|
+
/** Mark a session's SDK query stream as permanently ended and release the
|
|
272
|
+
* resources tied to it: drop the consumer handle, dispose the settings
|
|
273
|
+
* watchers, end the input stream, and close the query (which terminates the
|
|
274
|
+
* subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
|
|
275
|
+
* consult `queryClosed` and fail/short-circuit instead of acting on a dead
|
|
276
|
+
* stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
|
|
277
|
+
* paths and a later `teardownSession` can all call it without double-releasing.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately does NOT abort `session.abortController`: that controller may be
|
|
280
|
+
* CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
|
|
281
|
+
* aborting it on a spontaneous stream end would cancel the client's own work
|
|
282
|
+
* or make a sibling session born aborted. `query.close()` already terminates
|
|
283
|
+
* the subprocess; aborting the signal belongs in `teardownSession` (explicit
|
|
284
|
+
* destroy), not here. Also does NOT remove the session from the map — that is
|
|
285
|
+
* `teardownSession`'s job — so prompt() can still answer with a clear "session
|
|
286
|
+
* ended" error after an unexpected stream close. The leftover session object
|
|
287
|
+
* is a lightweight husk (its heavy resources are released here) and is evicted
|
|
288
|
+
* on the next closeSession/deleteSession or when the connection's `dispose()`
|
|
289
|
+
* runs. */
|
|
290
|
+
private closeQueryStream;
|
|
291
|
+
/** Cleanly tear down a session: cancel in-flight work, release stream
|
|
292
|
+
* resources, and remove it from the session map. */
|
|
226
293
|
private teardownSession;
|
|
227
294
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
228
295
|
dispose(): Promise<void>;
|
package/dist/acp-agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"acp-agent.d.ts","sourceRoot":"","sources":["../src/acp-agent.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,mBAAmB,EACnB,mBAAmB,EAGnB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EAEnB,iBAAiB,EACjB,kBAAkB,EAElB,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,oBAAoB,EAEpB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,EACnB,6BAA6B,EAC7B,8BAA8B,EAC9B,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,UAAU,EAKV,SAAS,EAGT,OAAO,EACP,cAAc,EAEd,gBAAgB,EAChB,KAAK,EAKL,gBAAgB,EAChB,0BAA0B,EAC1B,cAAc,EAGf,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,sCAAsC,CAAC;AAelG,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EASL,SAAS,EAKV,MAAM,YAAY,CAAC;AACpB,OAAO,EAAwC,QAAQ,EAAe,MAAM,YAAY,CAAC;AAEzF,eAAO,MAAM,iBAAiB,QACuC,CAAC;AAgBtE;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC9B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;CACjC;AAED,KAAK,gBAAgB,GAAG;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AA6BF;;;yDAGyD;AACzD,KAAK,iBAAiB,GAAG;IACvB,eAAe,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChF,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,OAAO,GAAG;IACb,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB,
|
|
1
|
+
{"version":3,"file":"acp-agent.d.ts","sourceRoot":"","sources":["../src/acp-agent.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,mBAAmB,EACnB,mBAAmB,EAGnB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EAEnB,iBAAiB,EACjB,kBAAkB,EAElB,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,oBAAoB,EAEpB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,EACnB,6BAA6B,EAC7B,8BAA8B,EAC9B,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,UAAU,EAKV,SAAS,EAGT,OAAO,EACP,cAAc,EAEd,gBAAgB,EAChB,KAAK,EAKL,gBAAgB,EAChB,0BAA0B,EAC1B,cAAc,EAGf,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,sCAAsC,CAAC;AAelG,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EASL,SAAS,EAKV,MAAM,YAAY,CAAC;AACpB,OAAO,EAAwC,QAAQ,EAAe,MAAM,YAAY,CAAC;AAEzF,eAAO,MAAM,iBAAiB,QACuC,CAAC;AAgBtE;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC9B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;CACjC;AAED,KAAK,gBAAgB,GAAG;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AA6BF;;;yDAGyD;AACzD,KAAK,iBAAiB,GAAG;IACvB,eAAe,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChF,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;iFAIiF;AACjF,KAAK,IAAI,GAAG;IACV;qEACiE;IACjE,UAAU,EAAE,MAAM,CAAC;IACnB;;4DAEwD;IACxD,kBAAkB,EAAE,OAAO,CAAC;IAC5B;2EACuE;IACvE,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;IAC5C,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,KAAK,OAAO,GAAG;IACb,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IAChC,SAAS,EAAE,OAAO,CAAC;IACnB;4EACwE;IACxE,SAAS,CAAC,EAAE,IAAI,EAAE,CAAC;IACnB;2EACuE;IACvE,UAAU,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB;;;;;;;mFAO+E;IAC/E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;iCAE6B;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB;;;;4CAIwC;IACxC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ;+EAC2E;IAC3E,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,eAAe,CAAC;IACjC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,eAAe,EAAE,eAAe,CAAC;IACjC;;;;;uEAKmE;IACnE,gBAAgB,CAAC,EAAE,eAAe,CAAC;IACnC;;kBAEc;IACd,gBAAgB,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;IACjD,kBAAkB,EAAE,OAAO,GAAG,gBAAgB,EAAE,CAAC;IACjD;;;;kEAI8D;IAC9D,iBAAiB,EAAE,MAAM,CAAC;IAC1B;yEACqE;IACrE,SAAS,EAAE,SAAS,CAAC;IACrB;;;;4CAIwC;IACxC,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;;;;;;;;;;;;;uBAgBmB;IACnB,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC,CAAC;AAcF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC;CACnC,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE;QACX;;;;;;;;;;;;;;WAcG;QACH,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB;;;;;;WAMG;QACH,kBAAkB,CAAC,EAAE,OAAO,GAAG,gBAAgB,EAAE,CAAC;KACnD,CAAC;IACF,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF;;GAEG;AACH,KAAK,eAAe,GAAG;IACrB;;;;;OAKG;IACH,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACjC,CAAC;CACH,CAAC;AAEF,KAAK,kBAAkB,GAAG,mBAAmB,GAAG;IAAE,KAAK,CAAC,EAAE,eAAe,CAAA;CAAE,CAAC;AAE5E;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE;QAEX,QAAQ,EAAE,MAAM,CAAC;QAEjB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB,CAAC;IAEF,aAAa,CAAC,EAAE;QACd,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,eAAe,CAAC,EAAE;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,aAAa,CAAC,EAAE;QACd,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;KACvB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG;QACb,IAAI,EAAE,UAAU,GAAG,iBAAiB,GAAG,cAAc,CAAC;QACtD,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,OAAO,CAAC;KAChB,CAAC;CACH,CAAC;AAEF,wBAAsB,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC,CAsCrD;AAuED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CA0B1E;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAEhE;AAYD,wBAAgB,qBAAqB,CACnC,WAAW,CAAC,EAAE,OAAO,EACrB,MAAM,GAAE,MAAgB,GACvB,cAAc,CA8BhB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,gBAAgB,EAAE,GAAG,SAAS,EAC3C,QAAQ,EAAE,MAAM,GACf,MAAM,CAiCR;AAGD,qBAAa,cAAe,YAAW,KAAK;IAC1C,QAAQ,EAAE;QACR,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;IACF,MAAM,EAAE,mBAAmB,CAAC;IAC5B,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC;;+BAE2B;IAC3B,kBAAkB,EAAE,MAAM,CAAiC;gBAE/C,MAAM,EAAE,mBAAmB,EAAE,MAAM,CAAC,EAAE,MAAM;IAMlD,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAoJnE,UAAU,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAYlE,oBAAoB,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAoB9E,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAU3E,WAAW,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAarE,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAkBxE,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQzD,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC;IA6C5D;qFACiF;IACjF,OAAO,CAAC,cAAc;IActB;;;;qEAIiE;YACnD,WAAW;IA0hCnB,MAAM,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEvD;;;;;;;;;;;;;;;;;;gBAkBY;IACZ,OAAO,CAAC,gBAAgB;IAWxB;yDACqD;YACvC,eAAe;IA2B7B,4EAA4E;IACtE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAQxE,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAU3E,cAAc,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAiB9E,sBAAsB,CAC1B,MAAM,EAAE,6BAA6B,GACpC,OAAO,CAAC,8BAA8B,CAAC;YAuE5B,gBAAgB;YAoChB,oBAAoB;IA6C5B,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAKxE,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAKjF,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU;IAoKzC;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAyB5B;;;;OAIG;YACW,qBAAqB;YA8BrB,2BAA2B;YAa3B,kBAAkB;YAmBlB,sBAAsB;YAoGtB,kBAAkB;IA2ChC;;;;;OAKG;YACW,WAAW;YAuBX,aAAa;CAoY5B;AA0gBD,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,cAAc,CA6EpE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,GAAG,MAAM,GAAG,SAAS,CAYrB;AAqBD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,GAAG,iBAAiB,EAAE,GAAG,gBAAgB,EAAE,GAAG,wBAAwB,EAAE,EACvF,IAAI,EAAE,WAAW,GAAG,MAAM,EAC1B,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,mBAAmB,EAC3B,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,SAAS,CAAC;IAMtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,mBAAmB,EAAE,CA6SvB;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,0BAA0B,EACnC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,mBAAmB,EAC3B,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,mBAAmB,EAAE,CAkDvB;AAED,wBAAgB,MAAM;;;EAWrB"}
|
package/dist/acp-agent.js
CHANGED
|
@@ -93,6 +93,10 @@ function isMuslLibc() {
|
|
|
93
93
|
function shouldHideClaudeAuth() {
|
|
94
94
|
return process.argv.includes("--hide-claude-auth");
|
|
95
95
|
}
|
|
96
|
+
/** Returned to clients when a prompt or cancel targets a session whose SDK
|
|
97
|
+
* query stream has already ended (ran to `done` or died). The stream is not
|
|
98
|
+
* revivable, so the only recovery is a fresh session. */
|
|
99
|
+
const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
|
|
96
100
|
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
97
101
|
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
98
102
|
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
@@ -460,112 +464,270 @@ export class ClaudeAcpAgent {
|
|
|
460
464
|
if (!session) {
|
|
461
465
|
throw new Error("Session not found");
|
|
462
466
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
467
|
+
// The SDK query stream already terminated (see `queryClosed`); its iterator
|
|
468
|
+
// can't be revived, so enqueueing here would hang on a deferred that never
|
|
469
|
+
// settles. Fail clearly and let the client start a fresh session.
|
|
470
|
+
if (session.queryClosed) {
|
|
471
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
472
|
+
}
|
|
473
|
+
const userMessage = promptToClaude(params);
|
|
474
|
+
const promptUuid = randomUUID();
|
|
475
|
+
userMessage.uuid = promptUuid;
|
|
476
|
+
// Local-only commands (e.g. `/clear`) return a result without replaying the
|
|
477
|
+
// user message, so the consumer can't promote the turn from the echo.
|
|
478
|
+
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
479
|
+
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
480
|
+
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
481
|
+
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
482
|
+
// turn, pushes the user message onto the streaming input, makes sure the
|
|
483
|
+
// consumer is running, and awaits the deferred.
|
|
484
|
+
const turn = {
|
|
485
|
+
promptUuid,
|
|
486
|
+
isLocalOnlyCommand,
|
|
487
|
+
settled: false,
|
|
488
|
+
resolve: () => { },
|
|
489
|
+
reject: () => { },
|
|
469
490
|
};
|
|
491
|
+
const response = new Promise((resolve, reject) => {
|
|
492
|
+
turn.resolve = resolve;
|
|
493
|
+
turn.reject = reject;
|
|
494
|
+
});
|
|
495
|
+
session.turnQueue ??= [];
|
|
496
|
+
session.turnQueue.push(turn);
|
|
497
|
+
session.input.push(userMessage);
|
|
498
|
+
this.ensureConsumer(session, params.sessionId);
|
|
499
|
+
return response;
|
|
500
|
+
}
|
|
501
|
+
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
502
|
+
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
503
|
+
ensureConsumer(session, sessionId) {
|
|
504
|
+
if (session.consumer) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Wake-up channel so cancel() can force the consumer to settle the active
|
|
508
|
+
// turn "cancelled" even when query.next() is wedged and never yields again
|
|
509
|
+
// (issue #680). The consumer re-arms it after each fire.
|
|
510
|
+
session.cancelController = new AbortController();
|
|
511
|
+
session.consumer = this.runConsumer(session, { sessionId });
|
|
512
|
+
session.consumer.catch((error) => {
|
|
513
|
+
this.logger.error(`Session ${sessionId}: consumer terminated unexpectedly: ${error}`);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/** The single, long-lived consumer of the SDK query stream for a session. It
|
|
517
|
+
* forwards every message as ACP `sessionUpdate`s (so background/between-turn
|
|
518
|
+
* output streams live, not just while a prompt is awaiting) and settles each
|
|
519
|
+
* Turn's deferred when that turn ends. Replaces the per-prompt message loop;
|
|
520
|
+
* `params` only carries the (session-invariant) `sessionId`. */
|
|
521
|
+
async runConsumer(session, params) {
|
|
522
|
+
// Per-turn scratch, reset whenever a turn becomes active. Kept as consumer
|
|
523
|
+
// locals (rather than per-Turn fields) because they describe the message
|
|
524
|
+
// currently being processed, which is sequential — exactly one turn is
|
|
525
|
+
// active at a time. Mirrors the locals the old per-prompt loop held.
|
|
470
526
|
let lastAssistantTotalUsage = null;
|
|
471
527
|
let lastAssistantUsage = null;
|
|
472
528
|
let lastAssistantModel = null;
|
|
473
529
|
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
|
474
530
|
// problem, billing), it sets a categorical `error` field on the
|
|
475
|
-
// `SDKAssistantMessage` that precedes the final `result` message. We
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
// pattern-matching on the human-readable message text.
|
|
531
|
+
// `SDKAssistantMessage` that precedes the final `result` message. We capture
|
|
532
|
+
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
533
|
+
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
479
534
|
let lastAssistantError;
|
|
480
535
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
481
536
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
482
|
-
// human-readable explanation
|
|
483
|
-
// to the user (the refused assistant message itself usually has no content)
|
|
484
|
-
// and report ACP's dedicated `refusal` stop reason.
|
|
537
|
+
// human-readable explanation so the terminal `result` can surface it.
|
|
485
538
|
let lastRefusalExplanation = null;
|
|
486
539
|
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
|
487
540
|
// `status` (compact_result success/failed) twice for a single failed
|
|
488
541
|
// compaction, and the two messages are indistinguishable — so we report the
|
|
489
|
-
// outcome only while a compaction is in progress, then clear this.
|
|
490
|
-
// `compacting` status sets it again, so every distinct compaction (e.g.
|
|
491
|
-
// repeated auto-compactions in a long turn) is still shown.
|
|
542
|
+
// outcome only while a compaction is in progress, then clear this.
|
|
492
543
|
let compactionInProgress = false;
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// consolidated assistant message and in the persisted transcript — so a turn
|
|
498
|
-
// keeps the same ACP `messageId` whether it is streamed live or replayed
|
|
499
|
-
// from history. The per-event uuid is unique per event and never persisted.
|
|
500
|
-
// A single value suffices because every streaming partial arrives with
|
|
501
|
-
// `parent_tool_use_id === null` (subagent work is folded into tool-result
|
|
502
|
-
// messages, never surfaced as partial streams).
|
|
544
|
+
// Anthropic API message id of the assistant message currently being
|
|
545
|
+
// streamed, captured from `message_start` so the streamed chunks that follow
|
|
546
|
+
// (whose delta events don't carry it) can all be tagged with the same,
|
|
547
|
+
// replay-stable id.
|
|
503
548
|
let currentStreamMessageId;
|
|
504
|
-
// Per-message-id record of which assistant content actually streamed live
|
|
505
|
-
// `stream_event` deltas, split by block type
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
// breaks behind Anthropic-protocol gateways that return a turn as a single
|
|
509
|
-
// non-streamed block (common with OpenAI-compatible proxies): no
|
|
510
|
-
// `content_block_delta` fires, so the assembled block is the only copy the
|
|
511
|
-
// client will ever see. We keep the filter per (id, block type) for content
|
|
512
|
-
// that did stream, and fall back to forwarding what did not. Split by type so
|
|
513
|
-
// a gateway that streams text but not thinking (or vice versa) doesn't lose
|
|
514
|
-
// the un-streamed block. Only top-level (`parent_tool_use_id === null`)
|
|
515
|
-
// streams are recorded — subagent text is never streamed and must stay
|
|
516
|
-
// filtered, as it is internal to the tool call.
|
|
549
|
+
// Per-message-id record of which assistant content actually streamed live
|
|
550
|
+
// via `stream_event` deltas, split by block type, so the consolidated
|
|
551
|
+
// `assistant` message can drop the duplicate blocks that already reached the
|
|
552
|
+
// client as chunks while still forwarding any that a non-streaming gateway
|
|
517
553
|
const streamedTextIds = new Set();
|
|
518
554
|
const streamedThinkingIds = new Set();
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
userMessage.uuid = promptUuid;
|
|
522
|
-
// These local-only commands return a result without replaying the user
|
|
523
|
-
// message. Mark promptReplayed=true so their result isn't consumed as a
|
|
524
|
-
// background task result.
|
|
525
|
-
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
526
|
-
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
527
|
-
if (session.promptRunning) {
|
|
528
|
-
session.input.push(userMessage);
|
|
529
|
-
const order = session.nextPendingOrder++;
|
|
530
|
-
const cancelled = await new Promise((resolve) => {
|
|
531
|
-
session.pendingMessages.set(promptUuid, { resolve, order });
|
|
532
|
-
});
|
|
533
|
-
if (cancelled) {
|
|
534
|
-
return { stopReason: "cancelled" };
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
else {
|
|
538
|
-
session.input.push(userMessage);
|
|
539
|
-
}
|
|
540
|
-
session.promptRunning = true;
|
|
541
|
-
let handedOff = false;
|
|
542
|
-
let errored = false;
|
|
555
|
+
// Stop reason accumulated for the active turn (result subtype, refusal,
|
|
556
|
+
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
|
543
557
|
let stopReason = "end_turn";
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
558
|
+
const resetTurnScratch = () => {
|
|
559
|
+
lastAssistantTotalUsage = null;
|
|
560
|
+
lastAssistantUsage = null;
|
|
561
|
+
lastAssistantModel = null;
|
|
562
|
+
lastAssistantError = undefined;
|
|
563
|
+
lastRefusalExplanation = null;
|
|
564
|
+
compactionInProgress = false;
|
|
565
|
+
currentStreamMessageId = undefined;
|
|
566
|
+
stopReason = "end_turn";
|
|
567
|
+
session.accumulatedUsage = {
|
|
568
|
+
inputTokens: 0,
|
|
569
|
+
outputTokens: 0,
|
|
570
|
+
cachedReadTokens: 0,
|
|
571
|
+
cachedWriteTokens: 0,
|
|
572
|
+
};
|
|
573
|
+
};
|
|
574
|
+
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
575
|
+
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
576
|
+
* enqueued after a prior cancel isn't treated as cancelled. Also clears any
|
|
577
|
+
* leftover orphan-skip count: since the SDK echoes/runs input FIFO, every
|
|
578
|
+
* orphan from a prior cancel has already arrived by the time a live turn
|
|
579
|
+
* activates, so a non-zero remainder means the SDK dropped a queued turn on
|
|
580
|
+
* interrupt (no orphan emitted) — drop the stale count so a later echo-less
|
|
581
|
+
* result isn't wrongly skipped. */
|
|
582
|
+
const activateTurn = (turn) => {
|
|
583
|
+
session.activeTurn = turn;
|
|
584
|
+
session.cancelled = false;
|
|
585
|
+
session.pendingOrphanResults = 0;
|
|
586
|
+
resetTurnScratch();
|
|
587
|
+
};
|
|
588
|
+
/** Ensure there is an active turn before a user-turn result that carries no
|
|
589
|
+
* echo to activate it, by promoting the queue head. Most turns are
|
|
590
|
+
* activated by their replayed user message before their result, but some
|
|
591
|
+
* legitimately produce a result with no matching echo: local-only commands
|
|
592
|
+
* (e.g. `/context`) and compaction (`/compact`, whose only user messages
|
|
593
|
+
* are the generated summary and a `<local-command-stdout>` replay — neither
|
|
594
|
+
* carries the prompt's uuid). Promoting the head settles those.
|
|
595
|
+
*
|
|
596
|
+
* But an echo-less result can also be an ORPHAN: cancel() settles+removes a
|
|
597
|
+
* queued turn whose user message was already pushed, so the SDK still runs
|
|
598
|
+
* it and emits a result with no uuid to match. Promoting the head for an
|
|
599
|
+
* orphan would misattribute its stop reason/usage to an unrelated later
|
|
600
|
+
* prompt. `session.pendingOrphanResults` counts exactly how many such
|
|
601
|
+
* orphans are still expected (FIFO, they arrive before any live turn's
|
|
602
|
+
* result), so we skip those and only promote once the count is drained. */
|
|
603
|
+
const ensureActiveTurn = () => {
|
|
604
|
+
if (session.activeTurn) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const head = (session.turnQueue ?? []).find((t) => !t.settled);
|
|
608
|
+
if (!head) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if ((session.pendingOrphanResults ?? 0) > 0) {
|
|
612
|
+
session.pendingOrphanResults--;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
activateTurn(head);
|
|
616
|
+
};
|
|
617
|
+
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
618
|
+
* backstop (the turn is over), and drop it from the queue. */
|
|
619
|
+
const settleActive = (result) => {
|
|
620
|
+
const turn = session.activeTurn;
|
|
621
|
+
if (!turn || turn.settled) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
turn.settled = true;
|
|
625
|
+
if (session.forceCancelTimer) {
|
|
626
|
+
clearTimeout(session.forceCancelTimer);
|
|
627
|
+
session.forceCancelTimer = undefined;
|
|
628
|
+
}
|
|
629
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
630
|
+
session.activeTurn = null;
|
|
631
|
+
turn.resolve(result);
|
|
632
|
+
};
|
|
633
|
+
/** Reject the active turn (auth required, error result, …) without tearing
|
|
634
|
+
* down the consumer: the stream continues to idle and later turns proceed. */
|
|
635
|
+
const failActive = (error) => {
|
|
636
|
+
if (session.forceCancelTimer) {
|
|
637
|
+
clearTimeout(session.forceCancelTimer);
|
|
638
|
+
session.forceCancelTimer = undefined;
|
|
639
|
+
}
|
|
640
|
+
const turn = session.activeTurn;
|
|
641
|
+
if (!turn || turn.settled) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
turn.settled = true;
|
|
645
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
646
|
+
session.activeTurn = null;
|
|
647
|
+
turn.reject(error);
|
|
648
|
+
};
|
|
649
|
+
/** Reject every in-flight turn — used when the stream dies. */
|
|
650
|
+
const failAllTurns = (error) => {
|
|
651
|
+
if (session.forceCancelTimer) {
|
|
652
|
+
clearTimeout(session.forceCancelTimer);
|
|
653
|
+
session.forceCancelTimer = undefined;
|
|
654
|
+
}
|
|
655
|
+
const turns = session.activeTurn
|
|
656
|
+
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
|
|
657
|
+
: [...(session.turnQueue ?? [])];
|
|
658
|
+
session.activeTurn = null;
|
|
659
|
+
session.turnQueue = [];
|
|
660
|
+
for (const turn of turns) {
|
|
661
|
+
if (!turn.settled) {
|
|
662
|
+
turn.settled = true;
|
|
663
|
+
turn.reject(error);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
// The wake-up channel cancel()/teardown aborts to force the active turn to
|
|
668
|
+
// settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
|
|
669
|
+
// after each fire so the consumer keeps serving later turns.
|
|
670
|
+
let cancelController = session.cancelController;
|
|
551
671
|
try {
|
|
552
672
|
while (true) {
|
|
553
673
|
const nextMessage = session.query.next();
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
674
|
+
// Fresh abort listener per iteration, removed when next() wins, so a
|
|
675
|
+
// long-lived session doesn't accumulate listeners on one signal.
|
|
676
|
+
let onAbort;
|
|
677
|
+
const abortRace = new Promise((resolve) => {
|
|
678
|
+
onAbort = () => resolve("abort");
|
|
679
|
+
cancelController.signal.addEventListener("abort", onAbort, { once: true });
|
|
680
|
+
});
|
|
681
|
+
const raced = await Promise.race([
|
|
682
|
+
nextMessage.then((result) => ({ kind: "message", result })),
|
|
683
|
+
abortRace,
|
|
684
|
+
]);
|
|
685
|
+
cancelController.signal.removeEventListener("abort", onAbort);
|
|
686
|
+
if (raced === "abort") {
|
|
687
|
+
// cancel()/teardown woke us. Abandon the in-flight next() (swallowing
|
|
688
|
+
// any later rejection so it can't surface as unhandled) and settle the
|
|
689
|
+
// active turn "cancelled" per the ACP contract. If the session is
|
|
690
|
+
// being torn down, stop; otherwise re-arm and keep consuming.
|
|
560
691
|
void nextMessage.catch(() => { });
|
|
561
|
-
|
|
692
|
+
settleActive({ stopReason: "cancelled" });
|
|
693
|
+
if (!this.sessions[params.sessionId]) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
cancelController = new AbortController();
|
|
697
|
+
session.cancelController = cancelController;
|
|
698
|
+
continue;
|
|
562
699
|
}
|
|
563
|
-
const { value: message, done } =
|
|
700
|
+
const { value: message, done } = raced.result;
|
|
564
701
|
if (done || !message) {
|
|
565
|
-
|
|
566
|
-
|
|
702
|
+
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
703
|
+
// stream resources — same order as the error paths (failAllTurns before
|
|
704
|
+
// closeQueryStream). Settling is the user-facing contract; resource
|
|
705
|
+
// release is best-effort cleanup, so a throw there must not pre-empt a
|
|
706
|
+
// turn's real outcome.
|
|
707
|
+
//
|
|
708
|
+
// Settle the turn that was in flight so its prompt() doesn't hang:
|
|
709
|
+
// cancelled if a cancel is pending, otherwise the accumulated outcome.
|
|
710
|
+
settleActive(session.cancelled
|
|
711
|
+
? { stopReason: "cancelled" }
|
|
712
|
+
: { stopReason, usage: sessionUsage(session) });
|
|
713
|
+
// Queued turns the SDK never started never ran, so reject them rather
|
|
714
|
+
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
715
|
+
// for a prompt that produced no output. (A cancel already settled the
|
|
716
|
+
// turns that were queued at cancel time and removed them, so anything
|
|
717
|
+
// still here was enqueued afterward and was not part of the cancel.)
|
|
718
|
+
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
719
|
+
if (!queued.settled) {
|
|
720
|
+
queued.settled = true;
|
|
721
|
+
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
722
|
+
}
|
|
567
723
|
}
|
|
568
|
-
|
|
724
|
+
session.turnQueue = [];
|
|
725
|
+
// The query iterator can't be revived, so close the session's stream
|
|
726
|
+
// (marks queryClosed, drops the consumer handle, releases the dead
|
|
727
|
+
// subprocess/settings resources) — a later prompt() then rejects up
|
|
728
|
+
// front rather than restarting a consumer on the exhausted stream.
|
|
729
|
+
this.closeQueryStream(session);
|
|
730
|
+
return;
|
|
569
731
|
}
|
|
570
732
|
if (session.emitRawSDKMessages &&
|
|
571
733
|
shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
|
|
@@ -663,10 +825,20 @@ export class ClaudeAcpAgent {
|
|
|
663
825
|
}
|
|
664
826
|
case "session_state_changed": {
|
|
665
827
|
if (message.state === "idle") {
|
|
828
|
+
// A non-cancelled turn already settled at its terminal
|
|
829
|
+
// `result` (issue #773), so this trailing `idle` is just
|
|
830
|
+
// absorbed. We must NOT settle `activeTurn` here in that case:
|
|
831
|
+
// `idle` carries no turn identity, and it can lag (the SDK
|
|
832
|
+
// flushes held-back results / drains background agents first),
|
|
833
|
+
// so by the time it arrives the SDK may have echoed the NEXT
|
|
834
|
+
// turn and activated it — settling now would resolve that new
|
|
835
|
+
// turn prematurely with end_turn and ~zero usage, dropping its
|
|
836
|
+
// real result. Only a cancelled turn relies on `idle`: its
|
|
837
|
+
// `result` is dropped at the `session.cancelled` guard, so it
|
|
838
|
+
// never settles at a result and must settle here.
|
|
666
839
|
if (session.cancelled) {
|
|
667
|
-
stopReason
|
|
840
|
+
settleActive({ stopReason: "cancelled" });
|
|
668
841
|
}
|
|
669
|
-
return { stopReason, usage: sessionUsage(session) };
|
|
670
842
|
}
|
|
671
843
|
break;
|
|
672
844
|
}
|
|
@@ -764,6 +936,24 @@ export class ClaudeAcpAgent {
|
|
|
764
936
|
});
|
|
765
937
|
break;
|
|
766
938
|
}
|
|
939
|
+
case "informational": {
|
|
940
|
+
// Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop
|
|
941
|
+
// hook blocked continuation). Surface the text so the user sees it
|
|
942
|
+
// instead of a silent stop. ACP's agent_message_chunk has no
|
|
943
|
+
// severity field, so fold the level into the text for the more
|
|
944
|
+
// prominent levels ('info' is transcript-only noise — leave plain).
|
|
945
|
+
const text = message.level === "info"
|
|
946
|
+
? message.content
|
|
947
|
+
: `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
|
|
948
|
+
await this.client.sessionUpdate({
|
|
949
|
+
sessionId: message.session_id,
|
|
950
|
+
update: {
|
|
951
|
+
sessionUpdate: "agent_message_chunk",
|
|
952
|
+
content: { type: "text", text },
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
767
957
|
case "hook_started":
|
|
768
958
|
case "hook_progress":
|
|
769
959
|
case "hook_response":
|
|
@@ -773,6 +963,11 @@ export class ClaudeAcpAgent {
|
|
|
773
963
|
case "task_progress":
|
|
774
964
|
case "task_updated":
|
|
775
965
|
break;
|
|
966
|
+
case "worker_shutting_down":
|
|
967
|
+
// A Remote Control worker announced a graceful teardown. This is a
|
|
968
|
+
// live-tail signal for remote clients to explain why a session went
|
|
969
|
+
// away; it's not meaningful for a local stdio ACP session.
|
|
970
|
+
break;
|
|
776
971
|
case "elicitation_complete": {
|
|
777
972
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
778
973
|
// dismiss any UI it opened for it. Only meaningful when the
|
|
@@ -802,11 +997,32 @@ export class ClaudeAcpAgent {
|
|
|
802
997
|
}
|
|
803
998
|
break;
|
|
804
999
|
case "result": {
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1000
|
+
// Task-notification followups are autonomous work triggered by a
|
|
1001
|
+
// task-notification system message, not by the user's prompt.
|
|
1002
|
+
// They should not influence the user-turn lifecycle (stop reason,
|
|
1003
|
+
// slash-command output forwarding) but their cost is real.
|
|
1004
|
+
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
1005
|
+
// A user-turn result needs an active turn so its stop reason is
|
|
1006
|
+
// attributed and the turn settles at idle. Local-only commands carry
|
|
1007
|
+
// no user-message echo to promote them, so do it here from the head.
|
|
1008
|
+
// Promote BEFORE accumulating usage, since activation resets the
|
|
1009
|
+
// accumulator — promoting after would discard this result's tokens.
|
|
1010
|
+
if (!isTaskNotification) {
|
|
1011
|
+
ensureActiveTurn();
|
|
1012
|
+
}
|
|
1013
|
+
// Accumulate usage into the user turn's tally. Skip task-notification
|
|
1014
|
+
// followups: their cost is real but is reported separately via the
|
|
1015
|
+
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
1016
|
+
// turn activation — so folding a task-notification result that lands
|
|
1017
|
+
// after the next turn is active (but before it settles) would leak
|
|
1018
|
+
// those tokens into that turn's PromptResponse.usage.
|
|
1019
|
+
if (!isTaskNotification) {
|
|
1020
|
+
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
1021
|
+
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
1022
|
+
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
1023
|
+
session.accumulatedUsage.cachedWriteTokens +=
|
|
1024
|
+
message.usage.cache_creation_input_tokens;
|
|
1025
|
+
}
|
|
810
1026
|
const matchingModelUsage = lastAssistantModel
|
|
811
1027
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
812
1028
|
: null;
|
|
@@ -817,11 +1033,6 @@ export class ClaudeAcpAgent {
|
|
|
817
1033
|
if (matchingModelUsage) {
|
|
818
1034
|
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
819
1035
|
}
|
|
820
|
-
// Task-notification followups are autonomous work triggered by a
|
|
821
|
-
// task-notification system message, not by the user's prompt.
|
|
822
|
-
// They should not influence the user-turn lifecycle (stop reason,
|
|
823
|
-
// slash-command output forwarding) but their cost is real.
|
|
824
|
-
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
825
1036
|
// Send usage_update notification
|
|
826
1037
|
if (lastAssistantTotalUsage !== null) {
|
|
827
1038
|
await this.client.sessionUpdate({
|
|
@@ -863,12 +1074,14 @@ export class ClaudeAcpAgent {
|
|
|
863
1074
|
});
|
|
864
1075
|
}
|
|
865
1076
|
stopReason = "refusal";
|
|
1077
|
+
settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
|
|
866
1078
|
break;
|
|
867
1079
|
}
|
|
868
1080
|
switch (message.subtype) {
|
|
869
1081
|
case "success": {
|
|
870
1082
|
if (message.result.includes("Please run /login")) {
|
|
871
|
-
|
|
1083
|
+
failActive(RequestError.authRequired());
|
|
1084
|
+
break;
|
|
872
1085
|
}
|
|
873
1086
|
if (message.stop_reason === "max_tokens") {
|
|
874
1087
|
if (!isTaskNotification) {
|
|
@@ -877,13 +1090,14 @@ export class ClaudeAcpAgent {
|
|
|
877
1090
|
break;
|
|
878
1091
|
}
|
|
879
1092
|
if (message.is_error) {
|
|
880
|
-
|
|
1093
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
|
|
1094
|
+
break;
|
|
881
1095
|
}
|
|
882
1096
|
// For local-only commands (no model invocation), the result
|
|
883
1097
|
// text is the command output — forward it to the client.
|
|
884
1098
|
// Task-notification followups never originate from a user
|
|
885
1099
|
// slash command, so skip the forwarding for them.
|
|
886
|
-
if (isLocalOnlyCommand && !isTaskNotification) {
|
|
1100
|
+
if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
|
|
887
1101
|
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
888
1102
|
await this.client.sessionUpdate(notification);
|
|
889
1103
|
}
|
|
@@ -898,7 +1112,8 @@ export class ClaudeAcpAgent {
|
|
|
898
1112
|
break;
|
|
899
1113
|
}
|
|
900
1114
|
if (message.is_error) {
|
|
901
|
-
|
|
1115
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1116
|
+
break;
|
|
902
1117
|
}
|
|
903
1118
|
if (!isTaskNotification) {
|
|
904
1119
|
stopReason = "end_turn";
|
|
@@ -909,7 +1124,8 @@ export class ClaudeAcpAgent {
|
|
|
909
1124
|
case "error_max_turns":
|
|
910
1125
|
case "error_max_structured_output_retries":
|
|
911
1126
|
if (message.is_error) {
|
|
912
|
-
|
|
1127
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1128
|
+
break;
|
|
913
1129
|
}
|
|
914
1130
|
if (!isTaskNotification) {
|
|
915
1131
|
stopReason = "max_turn_requests";
|
|
@@ -919,6 +1135,16 @@ export class ClaudeAcpAgent {
|
|
|
919
1135
|
unreachable(message, this.logger);
|
|
920
1136
|
break;
|
|
921
1137
|
}
|
|
1138
|
+
// Settle the user turn at its terminal result so the client unlocks
|
|
1139
|
+
// as soon as the answer is done, rather than waiting for the SDK's
|
|
1140
|
+
// trailing `idle` (which can lag while background work runs — issue
|
|
1141
|
+
// #773). The consumer keeps draining afterward (absorbing idle and
|
|
1142
|
+
// forwarding any background output). is_error/auth already settled
|
|
1143
|
+
// via failActive; cancellation is left to the idle/abort path.
|
|
1144
|
+
// settleActive is idempotent, so a duplicate idle is a no-op.
|
|
1145
|
+
if (!isTaskNotification && !session.cancelled) {
|
|
1146
|
+
settleActive({ stopReason, usage: sessionUsage(session) });
|
|
1147
|
+
}
|
|
922
1148
|
break;
|
|
923
1149
|
}
|
|
924
1150
|
case "stream_event": {
|
|
@@ -1002,36 +1228,49 @@ export class ClaudeAcpAgent {
|
|
|
1002
1228
|
}
|
|
1003
1229
|
case "user":
|
|
1004
1230
|
case "assistant": {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
//
|
|
1009
|
-
// consolidated message carries both ids, so this is where we learn
|
|
1010
|
-
// the uuid that the SDK's rewind/resume APIs key on for the id we
|
|
1011
|
-
// hand clients. Not read yet (see Session.messageIdToUuid).
|
|
1231
|
+
// Record the ACP messageId -> SDK uuid mapping for this message
|
|
1232
|
+
// (including replays). The consolidated message carries both ids, so
|
|
1233
|
+
// this is where we learn the uuid the SDK's rewind/resume APIs key on
|
|
1234
|
+
// for the id we hand clients. Not read yet (see messageIdToUuid).
|
|
1012
1235
|
const mappedMessageId = messageIdForGrouping(message);
|
|
1013
1236
|
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
|
|
1014
1237
|
session.messageIdToUuid.set(mappedMessageId, message.uuid);
|
|
1015
1238
|
}
|
|
1016
|
-
//
|
|
1239
|
+
// A replayed user message echoes a queued turn back in submission
|
|
1240
|
+
// order. The first echo promotes that turn to active; if a different
|
|
1241
|
+
// turn is still active, it is handed off (settled end_turn) first.
|
|
1242
|
+
// Done before the `cancelled` guard so a turn enqueued after a cancel
|
|
1243
|
+
// is still promoted — activateTurn() clears the flag. The turn's own
|
|
1244
|
+
// echo is then dropped from the feed (the client already shows it).
|
|
1017
1245
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
1018
|
-
|
|
1246
|
+
const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
|
|
1247
|
+
if (queued) {
|
|
1248
|
+
// Only (re)activate if this isn't already the active turn — a
|
|
1249
|
+
// turn promoted early (e.g. by a result that preceded its echo)
|
|
1250
|
+
// must not have its accumulated usage reset by its own echo.
|
|
1251
|
+
if (session.activeTurn !== queued) {
|
|
1252
|
+
if (session.activeTurn) {
|
|
1253
|
+
// Hand off the previous turn. If a cancel is pending for it
|
|
1254
|
+
// (its trailing idle hasn't arrived yet), settle it
|
|
1255
|
+
// "cancelled" per the ACP contract rather than "end_turn" —
|
|
1256
|
+
// otherwise a cancel followed quickly by the next prompt
|
|
1257
|
+
// would report the cancelled turn as a normal completion.
|
|
1258
|
+
settleActive(session.cancelled
|
|
1259
|
+
? { stopReason: "cancelled" }
|
|
1260
|
+
: { stopReason: "end_turn", usage: sessionUsage(session) });
|
|
1261
|
+
}
|
|
1262
|
+
activateTurn(queued);
|
|
1263
|
+
}
|
|
1019
1264
|
break;
|
|
1020
1265
|
}
|
|
1021
|
-
const pending = session.pendingMessages.get(message.uuid);
|
|
1022
|
-
if (pending) {
|
|
1023
|
-
pending.resolve(false);
|
|
1024
|
-
session.pendingMessages.delete(message.uuid);
|
|
1025
|
-
handedOff = true;
|
|
1026
|
-
// the current loop stops with end_turn,
|
|
1027
|
-
// the loop of the next prompt continues running
|
|
1028
|
-
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
|
1029
|
-
}
|
|
1030
1266
|
if ("isReplay" in message && message.isReplay) {
|
|
1031
|
-
//
|
|
1267
|
+
// Unrelated replay (e.g. the echo of an already-settled turn).
|
|
1032
1268
|
break;
|
|
1033
1269
|
}
|
|
1034
1270
|
}
|
|
1271
|
+
if (session.cancelled) {
|
|
1272
|
+
break;
|
|
1273
|
+
}
|
|
1035
1274
|
// Snapshot the latest top-level assistant usage and model so the
|
|
1036
1275
|
// next `result` can emit a usage_update tied to the right context
|
|
1037
1276
|
// window. Subagent messages are excluded to keep the snapshot
|
|
@@ -1096,7 +1335,8 @@ export class ClaudeAcpAgent {
|
|
|
1096
1335
|
message.message.content.length === 1 &&
|
|
1097
1336
|
message.message.content[0].type === "text" &&
|
|
1098
1337
|
message.message.content[0].text.includes("Please run /login")) {
|
|
1099
|
-
|
|
1338
|
+
failActive(RequestError.authRequired());
|
|
1339
|
+
break;
|
|
1100
1340
|
}
|
|
1101
1341
|
let content;
|
|
1102
1342
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
@@ -1127,6 +1367,13 @@ export class ClaudeAcpAgent {
|
|
|
1127
1367
|
}
|
|
1128
1368
|
return true;
|
|
1129
1369
|
});
|
|
1370
|
+
// The consolidated message is the last place this id is needed for
|
|
1371
|
+
// dedupe — drop it so the streamed-id sets stay bounded to in-flight
|
|
1372
|
+
// streams rather than growing for the session's whole life.
|
|
1373
|
+
if (id !== undefined) {
|
|
1374
|
+
streamedTextIds.delete(id);
|
|
1375
|
+
streamedThinkingIds.delete(id);
|
|
1376
|
+
}
|
|
1130
1377
|
}
|
|
1131
1378
|
else if (message.type === "assistant") {
|
|
1132
1379
|
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
|
@@ -1189,83 +1436,36 @@ export class ClaudeAcpAgent {
|
|
|
1189
1436
|
break;
|
|
1190
1437
|
}
|
|
1191
1438
|
}
|
|
1192
|
-
|
|
1439
|
+
// `while (true)` only exits via the `done` return above or the catch
|
|
1440
|
+
// below, so there is no normal fall-through here.
|
|
1193
1441
|
}
|
|
1194
1442
|
catch (error) {
|
|
1195
|
-
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
// the
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
this.logger.error(`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
catch (drainErr) {
|
|
1217
|
-
this.logger.error(`Session ${params.sessionId}: failed to drain query after prompt error:`, drainErr);
|
|
1218
|
-
}
|
|
1219
|
-
if (error instanceof RequestError || !(error instanceof Error)) {
|
|
1220
|
-
throw error;
|
|
1221
|
-
}
|
|
1222
|
-
const message = error.message;
|
|
1223
|
-
if (message.includes("ProcessTransport") ||
|
|
1224
|
-
message.includes("terminated process") ||
|
|
1225
|
-
message.includes("process exited with") ||
|
|
1226
|
-
message.includes("process terminated by signal") ||
|
|
1227
|
-
message.includes("Failed to write to process stdin")) {
|
|
1443
|
+
// The query stream itself died (a transport/process error surfaced from
|
|
1444
|
+
// query.next()). Turn-level failures (auth, error results) are handled
|
|
1445
|
+
// inline via failActive and never reach here. Reject every in-flight turn;
|
|
1446
|
+
// if the process is gone, tear the session down so the client starts fresh.
|
|
1447
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1448
|
+
const processDied = error instanceof Error &&
|
|
1449
|
+
(message.includes("ProcessTransport") ||
|
|
1450
|
+
message.includes("terminated process") ||
|
|
1451
|
+
message.includes("process exited with") ||
|
|
1452
|
+
message.includes("process terminated by signal") ||
|
|
1453
|
+
message.includes("Failed to write to process stdin"));
|
|
1454
|
+
// Either way the query iterator is finished and the consumer is exiting,
|
|
1455
|
+
// so release its resources via closeQueryStream (idempotent). A process
|
|
1456
|
+
// death is unrecoverable, so additionally evict the session so the client
|
|
1457
|
+
// starts fresh; other stream errors keep the session so prompt()/cancel()
|
|
1458
|
+
// can answer with a clear "session ended" error.
|
|
1459
|
+
if (processDied) {
|
|
1228
1460
|
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
1229
|
-
session.
|
|
1230
|
-
|
|
1461
|
+
failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
|
|
1462
|
+
this.closeQueryStream(session);
|
|
1231
1463
|
delete this.sessions[params.sessionId];
|
|
1232
|
-
throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
|
|
1233
1464
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
// — so disarm the force-cancel backstop and release the wake-up channel
|
|
1239
|
-
// (only if we still own it; a handoff installs the next prompt's).
|
|
1240
|
-
if (session.forceCancelTimer) {
|
|
1241
|
-
clearTimeout(session.forceCancelTimer);
|
|
1242
|
-
session.forceCancelTimer = undefined;
|
|
1243
|
-
}
|
|
1244
|
-
if (session.cancelController === cancelController) {
|
|
1245
|
-
session.cancelController = undefined;
|
|
1246
|
-
}
|
|
1247
|
-
if (!handedOff) {
|
|
1248
|
-
session.promptRunning = false;
|
|
1249
|
-
if (errored) {
|
|
1250
|
-
// The query stream was just drained — handing pending prompts off
|
|
1251
|
-
// onto it would let them race with the recovery. Cancel them so
|
|
1252
|
-
// each waiting prompt() returns stopReason: "cancelled" and the
|
|
1253
|
-
// client can decide whether to retry.
|
|
1254
|
-
for (const pending of session.pendingMessages.values()) {
|
|
1255
|
-
pending.resolve(true);
|
|
1256
|
-
}
|
|
1257
|
-
session.pendingMessages.clear();
|
|
1258
|
-
}
|
|
1259
|
-
else if (session.pendingMessages.size > 0) {
|
|
1260
|
-
// This usually should not happen, but in case the loop finishes
|
|
1261
|
-
// without claude sending all message replays, we resolve the
|
|
1262
|
-
// next pending prompt call to ensure no prompts get stuck.
|
|
1263
|
-
const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
|
|
1264
|
-
if (next) {
|
|
1265
|
-
next[1].resolve(false);
|
|
1266
|
-
session.pendingMessages.delete(next[0]);
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1465
|
+
else {
|
|
1466
|
+
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
1467
|
+
failAllTurns(error);
|
|
1468
|
+
this.closeQueryStream(session);
|
|
1269
1469
|
}
|
|
1270
1470
|
}
|
|
1271
1471
|
}
|
|
@@ -1274,23 +1474,46 @@ export class ClaudeAcpAgent {
|
|
|
1274
1474
|
if (!session) {
|
|
1275
1475
|
return;
|
|
1276
1476
|
}
|
|
1477
|
+
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
1478
|
+
// settled when it closed, and there is no live query to interrupt. Calling
|
|
1479
|
+
// query.interrupt() on a finished iterator could reject and surface from
|
|
1480
|
+
// this fire-and-forget notification, so there is nothing to do here.
|
|
1481
|
+
if (session.queryClosed) {
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1277
1484
|
session.cancelled = true;
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1485
|
+
// Settle queued turns that haven't started yet (no echo seen) right away —
|
|
1486
|
+
// they have no in-flight SDK work to interrupt. The active turn is settled
|
|
1487
|
+
// by the consumer when it observes the interrupt's trailing idle (or via the
|
|
1488
|
+
// backstop below). Mirrors the old pendingMessages cancellation.
|
|
1489
|
+
if (session.turnQueue) {
|
|
1490
|
+
let orphaned = 0;
|
|
1491
|
+
for (const turn of session.turnQueue) {
|
|
1492
|
+
if (turn !== session.activeTurn && !turn.settled) {
|
|
1493
|
+
turn.settled = true;
|
|
1494
|
+
turn.resolve({ stopReason: "cancelled" });
|
|
1495
|
+
orphaned++;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
// Each removed queued turn's user message was already pushed to the SDK,
|
|
1499
|
+
// which processes input FIFO and will still emit a result for it with no
|
|
1500
|
+
// uuid to match. Count those so the consumer skips them (see
|
|
1501
|
+
// ensureActiveTurn) rather than misattributing them to the head.
|
|
1502
|
+
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
|
|
1503
|
+
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
1504
|
+
}
|
|
1505
|
+
// Arm a backstop before interrupting: if a turn is actively consuming the
|
|
1506
|
+
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
|
1507
|
+
// block — issue #680), force the consumer to settle the active turn
|
|
1508
|
+
// "cancelled" after the floor elapses so the pending session/prompt still
|
|
1509
|
+
// resolves per the ACP cancellation contract instead of hanging forever. The
|
|
1510
|
+
// consumer clears this timer when interrupt() works and it settles through
|
|
1288
1511
|
// the normal idle path, so on healthy cancels it is armed but never fires.
|
|
1289
1512
|
//
|
|
1290
|
-
// Arm at most once per turn: the floor is an absolute ceiling from the
|
|
1291
|
-
//
|
|
1513
|
+
// Arm at most once per turn: the floor is an absolute ceiling from the first
|
|
1514
|
+
// cancel, so a client that re-sends cancel (each call still retries
|
|
1292
1515
|
// interrupt() below) can't keep pushing the deadline out.
|
|
1293
|
-
if (session.
|
|
1516
|
+
if (session.activeTurn &&
|
|
1294
1517
|
session.cancelController &&
|
|
1295
1518
|
!session.cancelController.signal.aborted &&
|
|
1296
1519
|
!session.forceCancelTimer) {
|
|
@@ -1302,8 +1525,37 @@ export class ClaudeAcpAgent {
|
|
|
1302
1525
|
}
|
|
1303
1526
|
await session.query.interrupt();
|
|
1304
1527
|
}
|
|
1305
|
-
/**
|
|
1306
|
-
*
|
|
1528
|
+
/** Mark a session's SDK query stream as permanently ended and release the
|
|
1529
|
+
* resources tied to it: drop the consumer handle, dispose the settings
|
|
1530
|
+
* watchers, end the input stream, and close the query (which terminates the
|
|
1531
|
+
* subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
|
|
1532
|
+
* consult `queryClosed` and fail/short-circuit instead of acting on a dead
|
|
1533
|
+
* stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
|
|
1534
|
+
* paths and a later `teardownSession` can all call it without double-releasing.
|
|
1535
|
+
*
|
|
1536
|
+
* Deliberately does NOT abort `session.abortController`: that controller may be
|
|
1537
|
+
* CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
|
|
1538
|
+
* aborting it on a spontaneous stream end would cancel the client's own work
|
|
1539
|
+
* or make a sibling session born aborted. `query.close()` already terminates
|
|
1540
|
+
* the subprocess; aborting the signal belongs in `teardownSession` (explicit
|
|
1541
|
+
* destroy), not here. Also does NOT remove the session from the map — that is
|
|
1542
|
+
* `teardownSession`'s job — so prompt() can still answer with a clear "session
|
|
1543
|
+
* ended" error after an unexpected stream close. The leftover session object
|
|
1544
|
+
* is a lightweight husk (its heavy resources are released here) and is evicted
|
|
1545
|
+
* on the next closeSession/deleteSession or when the connection's `dispose()`
|
|
1546
|
+
* runs. */
|
|
1547
|
+
closeQueryStream(session) {
|
|
1548
|
+
if (session.queryClosed) {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
session.queryClosed = true;
|
|
1552
|
+
session.consumer = undefined;
|
|
1553
|
+
session.settingsManager.dispose();
|
|
1554
|
+
session.input.end();
|
|
1555
|
+
session.query.close();
|
|
1556
|
+
}
|
|
1557
|
+
/** Cleanly tear down a session: cancel in-flight work, release stream
|
|
1558
|
+
* resources, and remove it from the session map. */
|
|
1307
1559
|
async teardownSession(sessionId) {
|
|
1308
1560
|
const session = this.sessions[sessionId];
|
|
1309
1561
|
if (!session) {
|
|
@@ -1311,20 +1563,23 @@ export class ClaudeAcpAgent {
|
|
|
1311
1563
|
}
|
|
1312
1564
|
await this.cancel({ sessionId });
|
|
1313
1565
|
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
1314
|
-
// wedged
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1566
|
+
// wedged consumer only wakes when `cancelController` aborts — closeQueryStream
|
|
1567
|
+
// below doesn't touch it. Since we're tearing the session down anyway, wake
|
|
1568
|
+
// the consumer now so the in-flight prompt() resolves immediately instead of
|
|
1569
|
+
// after the floor, and clear the timer so it can't outlive the deleted
|
|
1570
|
+
// session (it isn't unref'd and would otherwise keep the event loop alive
|
|
1571
|
+
// until it fires).
|
|
1320
1572
|
if (session.forceCancelTimer) {
|
|
1321
1573
|
clearTimeout(session.forceCancelTimer);
|
|
1322
1574
|
session.forceCancelTimer = undefined;
|
|
1323
1575
|
}
|
|
1324
1576
|
session.cancelController?.abort();
|
|
1325
|
-
|
|
1577
|
+
this.closeQueryStream(session);
|
|
1578
|
+
// Abort the SDK abort signal only on explicit destroy. closeQueryStream
|
|
1579
|
+
// leaves it alone (it may be a client-owned controller — see its doc), but
|
|
1580
|
+
// here the client has asked us to close the session, so signalling abort is
|
|
1581
|
+
// appropriate; query.close() above has already torn the subprocess down.
|
|
1326
1582
|
session.abortController.abort();
|
|
1327
|
-
session.query.close();
|
|
1328
1583
|
delete this.sessions[sessionId];
|
|
1329
1584
|
}
|
|
1330
1585
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
@@ -1348,9 +1603,16 @@ export class ClaudeAcpAgent {
|
|
|
1348
1603
|
return {};
|
|
1349
1604
|
}
|
|
1350
1605
|
async setSessionMode(params) {
|
|
1351
|
-
|
|
1606
|
+
const session = this.sessions[params.sessionId];
|
|
1607
|
+
if (!session) {
|
|
1352
1608
|
throw new Error("Session not found");
|
|
1353
1609
|
}
|
|
1610
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1611
|
+
// a husk and `query.setPermissionMode` below would act on a closed query.
|
|
1612
|
+
// Fail with the same clear message prompt()/cancel() give for a dead stream.
|
|
1613
|
+
if (session.queryClosed) {
|
|
1614
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1615
|
+
}
|
|
1354
1616
|
await this.applySessionMode(params.sessionId, params.modeId);
|
|
1355
1617
|
await this.updateConfigOption(params.sessionId, "mode", params.modeId);
|
|
1356
1618
|
return {};
|
|
@@ -1360,6 +1622,13 @@ export class ClaudeAcpAgent {
|
|
|
1360
1622
|
if (!session) {
|
|
1361
1623
|
throw new Error("Session not found");
|
|
1362
1624
|
}
|
|
1625
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1626
|
+
// a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
|
|
1627
|
+
// calls this triggers would act on a closed query. Fail with the same clear
|
|
1628
|
+
// message prompt()/cancel() give for a dead stream.
|
|
1629
|
+
if (session.queryClosed) {
|
|
1630
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1631
|
+
}
|
|
1363
1632
|
if (typeof params.value !== "string") {
|
|
1364
1633
|
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
1365
1634
|
}
|
|
@@ -2163,9 +2432,6 @@ export class ClaudeAcpAgent {
|
|
|
2163
2432
|
models,
|
|
2164
2433
|
modelInfos: allowedModels,
|
|
2165
2434
|
configOptions,
|
|
2166
|
-
promptRunning: false,
|
|
2167
|
-
pendingMessages: new Map(),
|
|
2168
|
-
nextPendingOrder: 0,
|
|
2169
2435
|
abortController,
|
|
2170
2436
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
2171
2437
|
contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAI1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAED;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;
|
|
1
|
+
{"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAI1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAED;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AA4CD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,wBAAwB,CAwD1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAC9B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC7D;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEzB;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAkCxB"}
|
package/dist/elicitation.js
CHANGED
|
@@ -70,6 +70,27 @@ function questionFieldKey(index) {
|
|
|
70
70
|
function questionCustomFieldKey(index) {
|
|
71
71
|
return `question_${index}_custom`;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* `_meta` key under which a bridged enum option carries its structured
|
|
75
|
+
* `description`/`preview`, since ACP's `EnumOption` has no field for either.
|
|
76
|
+
* Namespaced like the agent's other `_meta` extensions (`_claude/...`).
|
|
77
|
+
*/
|
|
78
|
+
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
|
79
|
+
/**
|
|
80
|
+
* Build the `_meta` payload that carries an option's structured `description`
|
|
81
|
+
* and optional `preview` for clients that can render them as distinct fields.
|
|
82
|
+
* Returns `undefined` when neither is present, so we don't emit an empty `_meta`.
|
|
83
|
+
*/
|
|
84
|
+
function askUserQuestionOptionMeta(option) {
|
|
85
|
+
const detail = {};
|
|
86
|
+
if (option.description) {
|
|
87
|
+
detail.description = option.description;
|
|
88
|
+
}
|
|
89
|
+
if (option.preview) {
|
|
90
|
+
detail.preview = option.preview;
|
|
91
|
+
}
|
|
92
|
+
return Object.keys(detail).length > 0 ? { [OPTION_META_KEY]: detail } : undefined;
|
|
93
|
+
}
|
|
73
94
|
/**
|
|
74
95
|
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
|
75
96
|
*
|
|
@@ -89,10 +110,25 @@ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId
|
|
|
89
110
|
const single = questions.length === 1;
|
|
90
111
|
const properties = {};
|
|
91
112
|
questions.forEach((question, index) => {
|
|
92
|
-
const options = question.options.map((option) =>
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
const options = question.options.map((option) => {
|
|
114
|
+
const enumOption = {
|
|
115
|
+
const: option.label,
|
|
116
|
+
// `title` stays a flattened "label — description" string so clients that
|
|
117
|
+
// only read `const`/`title` still surface the description. Clients that
|
|
118
|
+
// understand the `_meta` below should prefer its structured fields (and
|
|
119
|
+
// treat `const` as the clean label) rather than parsing this title.
|
|
120
|
+
title: option.description ? `${option.label} — ${option.description}` : option.label,
|
|
121
|
+
};
|
|
122
|
+
// The SDK option carries a `description` (secondary text) and an optional
|
|
123
|
+
// `preview` (mockups, code snippets, comparisons shown on focus). Neither
|
|
124
|
+
// has a structural slot in `EnumOption`, so forward them under ACP's
|
|
125
|
+
// reserved `_meta` extension point for clients that can render them.
|
|
126
|
+
const meta = askUserQuestionOptionMeta(option);
|
|
127
|
+
if (meta) {
|
|
128
|
+
enumOption._meta = meta;
|
|
129
|
+
}
|
|
130
|
+
return enumOption;
|
|
131
|
+
});
|
|
96
132
|
// For a single question the prompt is carried by `message`, so we don't
|
|
97
133
|
// repeat it in the field description. With multiple questions each field
|
|
98
134
|
// needs its own question text.
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.47.0",
|
|
7
7
|
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
|
8
8
|
"main": "dist/lib.js",
|
|
9
9
|
"types": "dist/lib.d.ts",
|
|
@@ -60,16 +60,16 @@
|
|
|
60
60
|
"author": "Zed Industries",
|
|
61
61
|
"license": "Apache-2.0",
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@agentclientprotocol/sdk": "0.
|
|
64
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
63
|
+
"@agentclientprotocol/sdk": "0.26.0",
|
|
64
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.179",
|
|
65
65
|
"zod": "^3.25.0 || ^4.0.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@anthropic-ai/sdk": "0.104.
|
|
68
|
+
"@anthropic-ai/sdk": "0.104.2",
|
|
69
69
|
"@eslint/js": "10.0.1",
|
|
70
70
|
"@types/node": "25.9.3",
|
|
71
|
-
"@typescript-eslint/eslint-plugin": "8.61.
|
|
72
|
-
"@typescript-eslint/parser": "8.61.
|
|
71
|
+
"@typescript-eslint/eslint-plugin": "8.61.1",
|
|
72
|
+
"@typescript-eslint/parser": "8.61.1",
|
|
73
73
|
"eslint": "10.5.0",
|
|
74
74
|
"eslint-config-prettier": "10.1.8",
|
|
75
75
|
"globals": "17.6.0",
|