claude-code-acp-ts 0.44.0 → 0.53.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-agent.d.ts +162 -24
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +911 -291
- package/dist/elicitation.d.ts +10 -8
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +71 -26
- package/dist/index.js +7 -4
- package/dist/settings.js +9 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +18 -7
- package/dist/utils.js +3 -5
- package/package.json +14 -10
package/dist/acp-agent.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
1
|
+
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
+
import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import * as fs from "node:fs/promises";
|
|
5
6
|
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
7
9
|
import packageJson from "../package.json" with { type: "json" };
|
|
8
10
|
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, mcpElicitationToCreateRequest, } from "./elicitation.js";
|
|
9
11
|
import { SettingsManager } from "./settings.js";
|
|
10
12
|
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
11
13
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
12
14
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
13
16
|
const MAX_TITLE_LENGTH = 256;
|
|
14
17
|
function sanitizeTitle(text) {
|
|
15
18
|
// Replace newlines and collapse whitespace
|
|
@@ -93,6 +96,10 @@ function isMuslLibc() {
|
|
|
93
96
|
function shouldHideClaudeAuth() {
|
|
94
97
|
return process.argv.includes("--hide-claude-auth");
|
|
95
98
|
}
|
|
99
|
+
/** Returned to clients when a prompt or cancel targets a session whose SDK
|
|
100
|
+
* query stream has already ended (ran to `done` or died). The stream is not
|
|
101
|
+
* revivable, so the only recovery is a fresh session. */
|
|
102
|
+
const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
|
|
96
103
|
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
97
104
|
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
98
105
|
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
@@ -242,13 +249,54 @@ export function describeAlwaysAllow(suggestions, toolName) {
|
|
|
242
249
|
}
|
|
243
250
|
return `Always Allow ${parts.join(" and ")}`;
|
|
244
251
|
}
|
|
245
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
|
254
|
+
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
|
255
|
+
* valid for the entire connection lifetime, so it is captured once at
|
|
256
|
+
* construction.
|
|
257
|
+
*/
|
|
258
|
+
class ClientConnection {
|
|
259
|
+
ctx;
|
|
260
|
+
constructor(ctx) {
|
|
261
|
+
this.ctx = ctx;
|
|
262
|
+
}
|
|
263
|
+
sessionUpdate(params) {
|
|
264
|
+
return this.ctx.notify(methods.client.session.update, params);
|
|
265
|
+
}
|
|
266
|
+
requestPermission(params, signal) {
|
|
267
|
+
return this.ctx.request(methods.client.session.requestPermission, params, {
|
|
268
|
+
cancellationSignal: signal,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
readTextFile(params) {
|
|
272
|
+
return this.ctx.request(methods.client.fs.readTextFile, params);
|
|
273
|
+
}
|
|
274
|
+
writeTextFile(params) {
|
|
275
|
+
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
|
276
|
+
}
|
|
277
|
+
unstable_createElicitation(params, signal) {
|
|
278
|
+
return this.ctx.request(methods.client.elicitation.create, params, {
|
|
279
|
+
cancellationSignal: signal,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
unstable_completeElicitation(params) {
|
|
283
|
+
return this.ctx.notify(methods.client.elicitation.complete, params);
|
|
284
|
+
}
|
|
285
|
+
extNotification(method, params) {
|
|
286
|
+
return this.ctx.notify(method, params);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
246
289
|
export class ClaudeAcpAgent {
|
|
290
|
+
sessions;
|
|
291
|
+
client;
|
|
292
|
+
clientCapabilities;
|
|
293
|
+
logger;
|
|
294
|
+
gatewayAuthRequest;
|
|
295
|
+
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
296
|
+
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
297
|
+
* tests can shrink it. */
|
|
298
|
+
forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
|
247
299
|
constructor(client, logger) {
|
|
248
|
-
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
249
|
-
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
250
|
-
* tests can shrink it. */
|
|
251
|
-
this.forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
|
252
300
|
this.sessions = {};
|
|
253
301
|
this.client = client;
|
|
254
302
|
this.logger = logger ?? console;
|
|
@@ -366,6 +414,9 @@ export class ClaudeAcpAgent {
|
|
|
366
414
|
http: true,
|
|
367
415
|
sse: true,
|
|
368
416
|
},
|
|
417
|
+
auth: {
|
|
418
|
+
logout: {},
|
|
419
|
+
},
|
|
369
420
|
loadSession: true,
|
|
370
421
|
sessionCapabilities: {
|
|
371
422
|
additionalDirectories: {},
|
|
@@ -448,6 +499,40 @@ export class ClaudeAcpAgent {
|
|
|
448
499
|
sessions,
|
|
449
500
|
};
|
|
450
501
|
}
|
|
502
|
+
/** Read the SDK-maintained title for a session and, if it changed since the
|
|
503
|
+
* last time we looked, notify the client with a `session_info_update`. The
|
|
504
|
+
* SDK has no push event for the title it auto-generates in the background, so
|
|
505
|
+
* we pull it at turn-end. A missing session file or read error is non-fatal:
|
|
506
|
+
* the title is best-effort and another turn will retry. */
|
|
507
|
+
async maybeUpdateSessionTitle(sessionId, session) {
|
|
508
|
+
let info;
|
|
509
|
+
try {
|
|
510
|
+
info = await getSessionInfo(sessionId, { dir: session.cwd });
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// `customTitle` is a user-set `/rename`; `summary` is the auto-generated
|
|
517
|
+
// title (or first prompt). Prefer the explicit title when present.
|
|
518
|
+
const rawTitle = info?.customTitle ?? info?.summary;
|
|
519
|
+
if (!rawTitle) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const title = sanitizeTitle(rawTitle);
|
|
523
|
+
if (title === session.lastTitle) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
session.lastTitle = title;
|
|
527
|
+
await this.client.sessionUpdate({
|
|
528
|
+
sessionId,
|
|
529
|
+
update: {
|
|
530
|
+
sessionUpdate: "session_info_update",
|
|
531
|
+
title,
|
|
532
|
+
updatedAt: new Date(info.lastModified).toISOString(),
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
451
536
|
async authenticate(_params) {
|
|
452
537
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
453
538
|
this.gatewayAuthRequest = _params;
|
|
@@ -455,117 +540,306 @@ export class ClaudeAcpAgent {
|
|
|
455
540
|
}
|
|
456
541
|
throw new Error("Method not implemented.");
|
|
457
542
|
}
|
|
543
|
+
async logout(_params) {
|
|
544
|
+
// Clear in-memory gateway credentials supplied via `authenticate`. The
|
|
545
|
+
// gateway method never touches the on-disk credential store, so dropping
|
|
546
|
+
// this reference is the whole logout for that path.
|
|
547
|
+
this.gatewayAuthRequest = undefined;
|
|
548
|
+
// For the Claude/Console login methods the credentials live in the native
|
|
549
|
+
// CLI's store (keychain or config dir), which only the binary can clear.
|
|
550
|
+
// `claude auth logout` is non-interactive and idempotent.
|
|
551
|
+
const cliPath = await claudeCliPath();
|
|
552
|
+
try {
|
|
553
|
+
await execFileAsync(cliPath, ["auth", "logout"]);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
const stderr = typeof error === "object" && error && "stderr" in error
|
|
557
|
+
? String(error.stderr).trim()
|
|
558
|
+
: undefined;
|
|
559
|
+
throw RequestError.internalError({ stderr: stderr || undefined }, `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
458
562
|
async prompt(params) {
|
|
459
563
|
const session = this.sessions[params.sessionId];
|
|
460
564
|
if (!session) {
|
|
461
565
|
throw new Error("Session not found");
|
|
462
566
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
567
|
+
// The SDK query stream already terminated (see `queryClosed`); its iterator
|
|
568
|
+
// can't be revived, so enqueueing here would hang on a deferred that never
|
|
569
|
+
// settles. Fail clearly and let the client start a fresh session.
|
|
570
|
+
if (session.queryClosed) {
|
|
571
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
572
|
+
}
|
|
573
|
+
const userMessage = promptToClaude(params);
|
|
574
|
+
const promptUuid = randomUUID();
|
|
575
|
+
userMessage.uuid = promptUuid;
|
|
576
|
+
// Local-only commands (e.g. `/clear`) return a result without replaying the
|
|
577
|
+
// user message, so the consumer can't promote the turn from the echo.
|
|
578
|
+
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
579
|
+
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
580
|
+
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
581
|
+
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
582
|
+
// turn, pushes the user message onto the streaming input, makes sure the
|
|
583
|
+
// consumer is running, and awaits the deferred.
|
|
584
|
+
const turn = {
|
|
585
|
+
promptUuid,
|
|
586
|
+
isLocalOnlyCommand,
|
|
587
|
+
settled: false,
|
|
588
|
+
resolve: () => { },
|
|
589
|
+
reject: () => { },
|
|
469
590
|
};
|
|
591
|
+
const response = new Promise((resolve, reject) => {
|
|
592
|
+
turn.resolve = resolve;
|
|
593
|
+
turn.reject = reject;
|
|
594
|
+
});
|
|
595
|
+
session.turnQueue ??= [];
|
|
596
|
+
session.turnQueue.push(turn);
|
|
597
|
+
session.input.push(userMessage);
|
|
598
|
+
this.ensureConsumer(session, params.sessionId);
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
601
|
+
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
602
|
+
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
603
|
+
ensureConsumer(session, sessionId) {
|
|
604
|
+
if (session.consumer) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Wake-up channel so cancel() can force the consumer to settle the active
|
|
608
|
+
// turn "cancelled" even when query.next() is wedged and never yields again
|
|
609
|
+
// (issue #680). The consumer re-arms it after each fire.
|
|
610
|
+
session.cancelController = new AbortController();
|
|
611
|
+
session.consumer = this.runConsumer(session, { sessionId });
|
|
612
|
+
session.consumer.catch((error) => {
|
|
613
|
+
this.logger.error(`Session ${sessionId}: consumer terminated unexpectedly: ${error}`);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
/** The single, long-lived consumer of the SDK query stream for a session. It
|
|
617
|
+
* forwards every message as ACP `sessionUpdate`s (so background/between-turn
|
|
618
|
+
* output streams live, not just while a prompt is awaiting) and settles each
|
|
619
|
+
* Turn's deferred when that turn ends. Replaces the per-prompt message loop;
|
|
620
|
+
* `params` only carries the (session-invariant) `sessionId`. */
|
|
621
|
+
async runConsumer(session, params) {
|
|
622
|
+
// Per-turn scratch, reset whenever a turn becomes active. Kept as consumer
|
|
623
|
+
// locals (rather than per-Turn fields) because they describe the message
|
|
624
|
+
// currently being processed, which is sequential — exactly one turn is
|
|
625
|
+
// active at a time. Mirrors the locals the old per-prompt loop held.
|
|
470
626
|
let lastAssistantTotalUsage = null;
|
|
471
627
|
let lastAssistantUsage = null;
|
|
472
628
|
let lastAssistantModel = null;
|
|
473
629
|
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
|
474
630
|
// 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.
|
|
631
|
+
// `SDKAssistantMessage` that precedes the final `result` message. We capture
|
|
632
|
+
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
633
|
+
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
479
634
|
let lastAssistantError;
|
|
480
635
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
481
636
|
// 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.
|
|
637
|
+
// human-readable explanation so the terminal `result` can surface it.
|
|
485
638
|
let lastRefusalExplanation = null;
|
|
486
639
|
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
|
487
640
|
// `status` (compact_result success/failed) twice for a single failed
|
|
488
641
|
// 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.
|
|
642
|
+
// outcome only while a compaction is in progress, then clear this.
|
|
492
643
|
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).
|
|
644
|
+
// Anthropic API message id of the assistant message currently being
|
|
645
|
+
// streamed, captured from `message_start` so the streamed chunks that follow
|
|
646
|
+
// (whose delta events don't carry it) can all be tagged with the same,
|
|
647
|
+
// replay-stable id.
|
|
503
648
|
let currentStreamMessageId;
|
|
504
|
-
//
|
|
505
|
-
// `stream_event` deltas
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// that
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
const streamedTextIds = new Set();
|
|
518
|
-
const streamedThinkingIds = new Set();
|
|
519
|
-
const userMessage = promptToClaude(params);
|
|
520
|
-
const promptUuid = randomUUID();
|
|
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;
|
|
649
|
+
// The text/thinking blocks that have actually streamed live as
|
|
650
|
+
// `stream_event` deltas for the message the next consolidated `assistant`
|
|
651
|
+
// will repeat, in stream order, each accumulated to its full streamed text.
|
|
652
|
+
// The consolidated handler diffs each assembled block against these and
|
|
653
|
+
// forwards only the un-streamed remainder — nothing if it streamed in full
|
|
654
|
+
// (the common case), the whole block if it never streamed (a non-streaming
|
|
655
|
+
// gateway), or just the tail if the stream was cut short mid-block. Matching
|
|
656
|
+
// on content rather than the Anthropic message id makes dedupe robust to
|
|
657
|
+
// gateways that don't carry a stable/matching id across the stream and the
|
|
658
|
+
// consolidated message. Reset after each consolidated message consumes it.
|
|
659
|
+
const streamedBlocks = [];
|
|
660
|
+
// Stop reason accumulated for the active turn (result subtype, refusal,
|
|
661
|
+
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
|
543
662
|
let stopReason = "end_turn";
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
663
|
+
const resetTurnScratch = () => {
|
|
664
|
+
lastAssistantTotalUsage = null;
|
|
665
|
+
lastAssistantUsage = null;
|
|
666
|
+
lastAssistantModel = null;
|
|
667
|
+
lastAssistantError = undefined;
|
|
668
|
+
lastRefusalExplanation = null;
|
|
669
|
+
compactionInProgress = false;
|
|
670
|
+
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
671
|
+
// activation can fire mid-message (the replayed user echo with
|
|
672
|
+
// --replay-user-messages lands between a message's blocks); clearing the
|
|
673
|
+
// streamed-content record on activation would drop the blocks that
|
|
674
|
+
// streamed before the echo, so the consolidated assistant message would
|
|
675
|
+
// re-emit them as duplicates. streamedBlocks is bounded instead by being
|
|
676
|
+
// cleared when each consolidated message consumes it. #785 stopped
|
|
677
|
+
// resetting the streamed-content tracking here but left this line.
|
|
678
|
+
stopReason = "end_turn";
|
|
679
|
+
session.accumulatedUsage = {
|
|
680
|
+
inputTokens: 0,
|
|
681
|
+
outputTokens: 0,
|
|
682
|
+
cachedReadTokens: 0,
|
|
683
|
+
cachedWriteTokens: 0,
|
|
684
|
+
};
|
|
685
|
+
};
|
|
686
|
+
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
687
|
+
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
688
|
+
* enqueued after a prior cancel isn't treated as cancelled. Also clears any
|
|
689
|
+
* leftover orphan-skip count: since the SDK echoes/runs input FIFO, every
|
|
690
|
+
* orphan from a prior cancel has already arrived by the time a live turn
|
|
691
|
+
* activates, so a non-zero remainder means the SDK dropped a queued turn on
|
|
692
|
+
* interrupt (no orphan emitted) — drop the stale count so a later echo-less
|
|
693
|
+
* result isn't wrongly skipped. */
|
|
694
|
+
const activateTurn = (turn) => {
|
|
695
|
+
session.activeTurn = turn;
|
|
696
|
+
session.cancelled = false;
|
|
697
|
+
session.pendingOrphanResults = 0;
|
|
698
|
+
resetTurnScratch();
|
|
699
|
+
};
|
|
700
|
+
/** Ensure there is an active turn before a user-turn result that carries no
|
|
701
|
+
* echo to activate it, by promoting the queue head. Most turns are
|
|
702
|
+
* activated by their replayed user message before their result, but some
|
|
703
|
+
* legitimately produce a result with no matching echo: local-only commands
|
|
704
|
+
* (e.g. `/context`) and compaction (`/compact`, whose only user messages
|
|
705
|
+
* are the generated summary and a `<local-command-stdout>` replay — neither
|
|
706
|
+
* carries the prompt's uuid). Promoting the head settles those.
|
|
707
|
+
*
|
|
708
|
+
* But an echo-less result can also be an ORPHAN: cancel() settles+removes a
|
|
709
|
+
* queued turn whose user message was already pushed, so the SDK still runs
|
|
710
|
+
* it and emits a result with no uuid to match. Promoting the head for an
|
|
711
|
+
* orphan would misattribute its stop reason/usage to an unrelated later
|
|
712
|
+
* prompt. `session.pendingOrphanResults` counts exactly how many such
|
|
713
|
+
* orphans are still expected (FIFO, they arrive before any live turn's
|
|
714
|
+
* result), so we skip those and only promote once the count is drained. */
|
|
715
|
+
const ensureActiveTurn = () => {
|
|
716
|
+
if (session.activeTurn) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const head = (session.turnQueue ?? []).find((t) => !t.settled);
|
|
720
|
+
if (!head) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if ((session.pendingOrphanResults ?? 0) > 0) {
|
|
724
|
+
session.pendingOrphanResults--;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
activateTurn(head);
|
|
728
|
+
};
|
|
729
|
+
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
730
|
+
* backstop (the turn is over), and drop it from the queue. */
|
|
731
|
+
const settleActive = (result) => {
|
|
732
|
+
const turn = session.activeTurn;
|
|
733
|
+
if (!turn || turn.settled) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
turn.settled = true;
|
|
737
|
+
if (session.forceCancelTimer) {
|
|
738
|
+
clearTimeout(session.forceCancelTimer);
|
|
739
|
+
session.forceCancelTimer = undefined;
|
|
740
|
+
}
|
|
741
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
742
|
+
session.activeTurn = null;
|
|
743
|
+
turn.resolve(result);
|
|
744
|
+
};
|
|
745
|
+
/** Reject the active turn (auth required, error result, …) without tearing
|
|
746
|
+
* down the consumer: the stream continues to idle and later turns proceed. */
|
|
747
|
+
const failActive = (error) => {
|
|
748
|
+
if (session.forceCancelTimer) {
|
|
749
|
+
clearTimeout(session.forceCancelTimer);
|
|
750
|
+
session.forceCancelTimer = undefined;
|
|
751
|
+
}
|
|
752
|
+
const turn = session.activeTurn;
|
|
753
|
+
if (!turn || turn.settled) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
turn.settled = true;
|
|
757
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
758
|
+
session.activeTurn = null;
|
|
759
|
+
turn.reject(error);
|
|
760
|
+
};
|
|
761
|
+
/** Reject every in-flight turn — used when the stream dies. */
|
|
762
|
+
const failAllTurns = (error) => {
|
|
763
|
+
if (session.forceCancelTimer) {
|
|
764
|
+
clearTimeout(session.forceCancelTimer);
|
|
765
|
+
session.forceCancelTimer = undefined;
|
|
766
|
+
}
|
|
767
|
+
const turns = session.activeTurn
|
|
768
|
+
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
|
|
769
|
+
: [...(session.turnQueue ?? [])];
|
|
770
|
+
session.activeTurn = null;
|
|
771
|
+
session.turnQueue = [];
|
|
772
|
+
for (const turn of turns) {
|
|
773
|
+
if (!turn.settled) {
|
|
774
|
+
turn.settled = true;
|
|
775
|
+
turn.reject(error);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
// The wake-up channel cancel()/teardown aborts to force the active turn to
|
|
780
|
+
// settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
|
|
781
|
+
// after each fire so the consumer keeps serving later turns.
|
|
782
|
+
let cancelController = session.cancelController;
|
|
551
783
|
try {
|
|
552
784
|
while (true) {
|
|
553
785
|
const nextMessage = session.query.next();
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
786
|
+
// Fresh abort listener per iteration, removed when next() wins, so a
|
|
787
|
+
// long-lived session doesn't accumulate listeners on one signal.
|
|
788
|
+
let onAbort;
|
|
789
|
+
const abortRace = new Promise((resolve) => {
|
|
790
|
+
onAbort = () => resolve("abort");
|
|
791
|
+
cancelController.signal.addEventListener("abort", onAbort, { once: true });
|
|
792
|
+
});
|
|
793
|
+
const raced = await Promise.race([
|
|
794
|
+
nextMessage.then((result) => ({ kind: "message", result })),
|
|
795
|
+
abortRace,
|
|
796
|
+
]);
|
|
797
|
+
cancelController.signal.removeEventListener("abort", onAbort);
|
|
798
|
+
if (raced === "abort") {
|
|
799
|
+
// cancel()/teardown woke us. Abandon the in-flight next() (swallowing
|
|
800
|
+
// any later rejection so it can't surface as unhandled) and settle the
|
|
801
|
+
// active turn "cancelled" per the ACP contract. If the session is
|
|
802
|
+
// being torn down, stop; otherwise re-arm and keep consuming.
|
|
560
803
|
void nextMessage.catch(() => { });
|
|
561
|
-
|
|
804
|
+
settleActive({ stopReason: "cancelled" });
|
|
805
|
+
if (!this.sessions[params.sessionId]) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
cancelController = new AbortController();
|
|
809
|
+
session.cancelController = cancelController;
|
|
810
|
+
continue;
|
|
562
811
|
}
|
|
563
|
-
const { value: message, done } =
|
|
812
|
+
const { value: message, done } = raced.result;
|
|
564
813
|
if (done || !message) {
|
|
565
|
-
|
|
566
|
-
|
|
814
|
+
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
815
|
+
// stream resources — same order as the error paths (failAllTurns before
|
|
816
|
+
// closeQueryStream). Settling is the user-facing contract; resource
|
|
817
|
+
// release is best-effort cleanup, so a throw there must not pre-empt a
|
|
818
|
+
// turn's real outcome.
|
|
819
|
+
//
|
|
820
|
+
// Settle the turn that was in flight so its prompt() doesn't hang:
|
|
821
|
+
// cancelled if a cancel is pending, otherwise the accumulated outcome.
|
|
822
|
+
settleActive(session.cancelled
|
|
823
|
+
? { stopReason: "cancelled" }
|
|
824
|
+
: { stopReason, usage: sessionUsage(session) });
|
|
825
|
+
// Queued turns the SDK never started never ran, so reject them rather
|
|
826
|
+
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
827
|
+
// for a prompt that produced no output. (A cancel already settled the
|
|
828
|
+
// turns that were queued at cancel time and removed them, so anything
|
|
829
|
+
// still here was enqueued afterward and was not part of the cancel.)
|
|
830
|
+
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
831
|
+
if (!queued.settled) {
|
|
832
|
+
queued.settled = true;
|
|
833
|
+
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
834
|
+
}
|
|
567
835
|
}
|
|
568
|
-
|
|
836
|
+
session.turnQueue = [];
|
|
837
|
+
// The query iterator can't be revived, so close the session's stream
|
|
838
|
+
// (marks queryClosed, drops the consumer handle, releases the dead
|
|
839
|
+
// subprocess/settings resources) — a later prompt() then rejects up
|
|
840
|
+
// front rather than restarting a consumer on the exhausted stream.
|
|
841
|
+
this.closeQueryStream(session);
|
|
842
|
+
return;
|
|
569
843
|
}
|
|
570
844
|
if (session.emitRawSDKMessages &&
|
|
571
845
|
shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
|
|
@@ -663,10 +937,25 @@ export class ClaudeAcpAgent {
|
|
|
663
937
|
}
|
|
664
938
|
case "session_state_changed": {
|
|
665
939
|
if (message.state === "idle") {
|
|
940
|
+
// A non-cancelled turn already settled at its terminal
|
|
941
|
+
// `result` (issue #773), so this trailing `idle` is just
|
|
942
|
+
// absorbed. We must NOT settle `activeTurn` here in that case:
|
|
943
|
+
// `idle` carries no turn identity, and it can lag (the SDK
|
|
944
|
+
// flushes held-back results / drains background agents first),
|
|
945
|
+
// so by the time it arrives the SDK may have echoed the NEXT
|
|
946
|
+
// turn and activated it — settling now would resolve that new
|
|
947
|
+
// turn prematurely with end_turn and ~zero usage, dropping its
|
|
948
|
+
// real result. Only a cancelled turn relies on `idle`: its
|
|
949
|
+
// `result` is dropped at the `session.cancelled` guard, so it
|
|
950
|
+
// never settles at a result and must settle here.
|
|
666
951
|
if (session.cancelled) {
|
|
667
|
-
stopReason
|
|
952
|
+
settleActive({ stopReason: "cancelled" });
|
|
668
953
|
}
|
|
669
|
-
|
|
954
|
+
// The SDK generates the session title in a background task and
|
|
955
|
+
// persists it to the session file; `idle` is the turn-over
|
|
956
|
+
// signal, so it's the point at which a new title may have
|
|
957
|
+
// landed. Push it to the client if it changed.
|
|
958
|
+
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
670
959
|
}
|
|
671
960
|
break;
|
|
672
961
|
}
|
|
@@ -764,6 +1053,24 @@ export class ClaudeAcpAgent {
|
|
|
764
1053
|
});
|
|
765
1054
|
break;
|
|
766
1055
|
}
|
|
1056
|
+
case "informational": {
|
|
1057
|
+
// Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop
|
|
1058
|
+
// hook blocked continuation). Surface the text so the user sees it
|
|
1059
|
+
// instead of a silent stop. ACP's agent_message_chunk has no
|
|
1060
|
+
// severity field, so fold the level into the text for the more
|
|
1061
|
+
// prominent levels ('info' is transcript-only noise — leave plain).
|
|
1062
|
+
const text = message.level === "info"
|
|
1063
|
+
? message.content
|
|
1064
|
+
: `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
|
|
1065
|
+
await this.client.sessionUpdate({
|
|
1066
|
+
sessionId: message.session_id,
|
|
1067
|
+
update: {
|
|
1068
|
+
sessionUpdate: "agent_message_chunk",
|
|
1069
|
+
content: { type: "text", text },
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
767
1074
|
case "hook_started":
|
|
768
1075
|
case "hook_progress":
|
|
769
1076
|
case "hook_response":
|
|
@@ -773,6 +1080,11 @@ export class ClaudeAcpAgent {
|
|
|
773
1080
|
case "task_progress":
|
|
774
1081
|
case "task_updated":
|
|
775
1082
|
break;
|
|
1083
|
+
case "worker_shutting_down":
|
|
1084
|
+
// A Remote Control worker announced a graceful teardown. This is a
|
|
1085
|
+
// live-tail signal for remote clients to explain why a session went
|
|
1086
|
+
// away; it's not meaningful for a local stdio ACP session.
|
|
1087
|
+
break;
|
|
776
1088
|
case "elicitation_complete": {
|
|
777
1089
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
778
1090
|
// dismiss any UI it opened for it. Only meaningful when the
|
|
@@ -802,11 +1114,32 @@ export class ClaudeAcpAgent {
|
|
|
802
1114
|
}
|
|
803
1115
|
break;
|
|
804
1116
|
case "result": {
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1117
|
+
// Task-notification followups are autonomous work triggered by a
|
|
1118
|
+
// task-notification system message, not by the user's prompt.
|
|
1119
|
+
// They should not influence the user-turn lifecycle (stop reason,
|
|
1120
|
+
// slash-command output forwarding) but their cost is real.
|
|
1121
|
+
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
1122
|
+
// A user-turn result needs an active turn so its stop reason is
|
|
1123
|
+
// attributed and the turn settles at idle. Local-only commands carry
|
|
1124
|
+
// no user-message echo to promote them, so do it here from the head.
|
|
1125
|
+
// Promote BEFORE accumulating usage, since activation resets the
|
|
1126
|
+
// accumulator — promoting after would discard this result's tokens.
|
|
1127
|
+
if (!isTaskNotification) {
|
|
1128
|
+
ensureActiveTurn();
|
|
1129
|
+
}
|
|
1130
|
+
// Accumulate usage into the user turn's tally. Skip task-notification
|
|
1131
|
+
// followups: their cost is real but is reported separately via the
|
|
1132
|
+
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
1133
|
+
// turn activation — so folding a task-notification result that lands
|
|
1134
|
+
// after the next turn is active (but before it settles) would leak
|
|
1135
|
+
// those tokens into that turn's PromptResponse.usage.
|
|
1136
|
+
if (!isTaskNotification) {
|
|
1137
|
+
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
1138
|
+
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
1139
|
+
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
1140
|
+
session.accumulatedUsage.cachedWriteTokens +=
|
|
1141
|
+
message.usage.cache_creation_input_tokens;
|
|
1142
|
+
}
|
|
810
1143
|
const matchingModelUsage = lastAssistantModel
|
|
811
1144
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
812
1145
|
: null;
|
|
@@ -817,11 +1150,6 @@ export class ClaudeAcpAgent {
|
|
|
817
1150
|
if (matchingModelUsage) {
|
|
818
1151
|
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
819
1152
|
}
|
|
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
1153
|
// Send usage_update notification
|
|
826
1154
|
if (lastAssistantTotalUsage !== null) {
|
|
827
1155
|
await this.client.sessionUpdate({
|
|
@@ -863,12 +1191,14 @@ export class ClaudeAcpAgent {
|
|
|
863
1191
|
});
|
|
864
1192
|
}
|
|
865
1193
|
stopReason = "refusal";
|
|
1194
|
+
settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
|
|
866
1195
|
break;
|
|
867
1196
|
}
|
|
868
1197
|
switch (message.subtype) {
|
|
869
1198
|
case "success": {
|
|
870
1199
|
if (message.result.includes("Please run /login")) {
|
|
871
|
-
|
|
1200
|
+
failActive(RequestError.authRequired());
|
|
1201
|
+
break;
|
|
872
1202
|
}
|
|
873
1203
|
if (message.stop_reason === "max_tokens") {
|
|
874
1204
|
if (!isTaskNotification) {
|
|
@@ -877,13 +1207,14 @@ export class ClaudeAcpAgent {
|
|
|
877
1207
|
break;
|
|
878
1208
|
}
|
|
879
1209
|
if (message.is_error) {
|
|
880
|
-
|
|
1210
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
|
|
1211
|
+
break;
|
|
881
1212
|
}
|
|
882
1213
|
// For local-only commands (no model invocation), the result
|
|
883
1214
|
// text is the command output — forward it to the client.
|
|
884
1215
|
// Task-notification followups never originate from a user
|
|
885
1216
|
// slash command, so skip the forwarding for them.
|
|
886
|
-
if (isLocalOnlyCommand && !isTaskNotification) {
|
|
1217
|
+
if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
|
|
887
1218
|
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
888
1219
|
await this.client.sessionUpdate(notification);
|
|
889
1220
|
}
|
|
@@ -898,7 +1229,8 @@ export class ClaudeAcpAgent {
|
|
|
898
1229
|
break;
|
|
899
1230
|
}
|
|
900
1231
|
if (message.is_error) {
|
|
901
|
-
|
|
1232
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1233
|
+
break;
|
|
902
1234
|
}
|
|
903
1235
|
if (!isTaskNotification) {
|
|
904
1236
|
stopReason = "end_turn";
|
|
@@ -909,7 +1241,8 @@ export class ClaudeAcpAgent {
|
|
|
909
1241
|
case "error_max_turns":
|
|
910
1242
|
case "error_max_structured_output_retries":
|
|
911
1243
|
if (message.is_error) {
|
|
912
|
-
|
|
1244
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1245
|
+
break;
|
|
913
1246
|
}
|
|
914
1247
|
if (!isTaskNotification) {
|
|
915
1248
|
stopReason = "max_turn_requests";
|
|
@@ -919,6 +1252,16 @@ export class ClaudeAcpAgent {
|
|
|
919
1252
|
unreachable(message, this.logger);
|
|
920
1253
|
break;
|
|
921
1254
|
}
|
|
1255
|
+
// Settle the user turn at its terminal result so the client unlocks
|
|
1256
|
+
// as soon as the answer is done, rather than waiting for the SDK's
|
|
1257
|
+
// trailing `idle` (which can lag while background work runs — issue
|
|
1258
|
+
// #773). The consumer keeps draining afterward (absorbing idle and
|
|
1259
|
+
// forwarding any background output). is_error/auth already settled
|
|
1260
|
+
// via failActive; cancellation is left to the idle/abort path.
|
|
1261
|
+
// settleActive is idempotent, so a duplicate idle is a no-op.
|
|
1262
|
+
if (!isTaskNotification && !session.cancelled) {
|
|
1263
|
+
settleActive({ stopReason, usage: sessionUsage(session) });
|
|
1264
|
+
}
|
|
922
1265
|
break;
|
|
923
1266
|
}
|
|
924
1267
|
case "stream_event": {
|
|
@@ -927,20 +1270,50 @@ export class ClaudeAcpAgent {
|
|
|
927
1270
|
// it) can all be tagged with the same, replay-stable id.
|
|
928
1271
|
if (message.event.type === "message_start") {
|
|
929
1272
|
currentStreamMessageId = message.event.message.id || undefined;
|
|
1273
|
+
// A new top-level message starts: clear any streamed-content
|
|
1274
|
+
// residue from a prior message that never reached its
|
|
1275
|
+
// consolidated reset — a cancelled turn breaks out before the
|
|
1276
|
+
// reset, and the synthetic-auth/system/local-command paths
|
|
1277
|
+
// `break` early too. Block indices restart at 0 each message, so
|
|
1278
|
+
// leftover entries would otherwise collide with this message's
|
|
1279
|
+
// blocks and re-emit (or truncate) already-streamed text. Gated on
|
|
1280
|
+
// `parent_tool_use_id === null` so a subagent stream can't clear
|
|
1281
|
+
// the top-level record. Fires once, before any of this message's
|
|
1282
|
+
// blocks, so it doesn't disturb the mid-message turn-activation
|
|
1283
|
+
// path the way resetting on turn activation would.
|
|
1284
|
+
if (message.parent_tool_use_id === null) {
|
|
1285
|
+
streamedBlocks.length = 0;
|
|
1286
|
+
}
|
|
930
1287
|
}
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
// can't attribute its content to the top-level message
|
|
936
|
-
|
|
937
|
-
|
|
1288
|
+
// Accumulate the text/thinking actually streamed live, so the
|
|
1289
|
+
// `assistant` case below can diff its assembled blocks against what
|
|
1290
|
+
// already reached the client as chunks and forward only the
|
|
1291
|
+
// remainder. Gated on `parent_tool_use_id === null` so a subagent
|
|
1292
|
+
// stream can't attribute its content to the top-level message.
|
|
1293
|
+
// Contiguous deltas of the same block (same index and type) extend
|
|
1294
|
+
// the current entry; anything else opens a new one.
|
|
1295
|
+
if (message.parent_tool_use_id === null &&
|
|
938
1296
|
message.event.type === "content_block_delta") {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1297
|
+
const delta = message.event.delta;
|
|
1298
|
+
const chunk = delta.type === "text_delta"
|
|
1299
|
+
? { type: "text", text: delta.text }
|
|
1300
|
+
: delta.type === "thinking_delta"
|
|
1301
|
+
? { type: "thinking", text: delta.thinking }
|
|
1302
|
+
: undefined;
|
|
1303
|
+
// Skip empty deltas (some gateways emit empty thinking chunks —
|
|
1304
|
+
// #793): appending "" is a no-op, but pushing a "" entry would
|
|
1305
|
+
// create a block the consolidated handler's `text.length > 0`
|
|
1306
|
+
// guard can never consume, stalling the diff cursor and
|
|
1307
|
+
// re-emitting the next block as a duplicate.
|
|
1308
|
+
if (chunk && chunk.text.length > 0) {
|
|
1309
|
+
const index = message.event.index;
|
|
1310
|
+
const last = streamedBlocks[streamedBlocks.length - 1];
|
|
1311
|
+
if (last && last.index === index && last.type === chunk.type) {
|
|
1312
|
+
last.text += chunk.text;
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
|
|
1316
|
+
}
|
|
944
1317
|
}
|
|
945
1318
|
}
|
|
946
1319
|
if (message.parent_tool_use_id === null &&
|
|
@@ -1002,36 +1375,49 @@ export class ClaudeAcpAgent {
|
|
|
1002
1375
|
}
|
|
1003
1376
|
case "user":
|
|
1004
1377
|
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).
|
|
1378
|
+
// Record the ACP messageId -> SDK uuid mapping for this message
|
|
1379
|
+
// (including replays). The consolidated message carries both ids, so
|
|
1380
|
+
// this is where we learn the uuid the SDK's rewind/resume APIs key on
|
|
1381
|
+
// for the id we hand clients. Not read yet (see messageIdToUuid).
|
|
1012
1382
|
const mappedMessageId = messageIdForGrouping(message);
|
|
1013
1383
|
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
|
|
1014
1384
|
session.messageIdToUuid.set(mappedMessageId, message.uuid);
|
|
1015
1385
|
}
|
|
1016
|
-
//
|
|
1386
|
+
// A replayed user message echoes a queued turn back in submission
|
|
1387
|
+
// order. The first echo promotes that turn to active; if a different
|
|
1388
|
+
// turn is still active, it is handed off (settled end_turn) first.
|
|
1389
|
+
// Done before the `cancelled` guard so a turn enqueued after a cancel
|
|
1390
|
+
// is still promoted — activateTurn() clears the flag. The turn's own
|
|
1391
|
+
// echo is then dropped from the feed (the client already shows it).
|
|
1017
1392
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
1018
|
-
|
|
1393
|
+
const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
|
|
1394
|
+
if (queued) {
|
|
1395
|
+
// Only (re)activate if this isn't already the active turn — a
|
|
1396
|
+
// turn promoted early (e.g. by a result that preceded its echo)
|
|
1397
|
+
// must not have its accumulated usage reset by its own echo.
|
|
1398
|
+
if (session.activeTurn !== queued) {
|
|
1399
|
+
if (session.activeTurn) {
|
|
1400
|
+
// Hand off the previous turn. If a cancel is pending for it
|
|
1401
|
+
// (its trailing idle hasn't arrived yet), settle it
|
|
1402
|
+
// "cancelled" per the ACP contract rather than "end_turn" —
|
|
1403
|
+
// otherwise a cancel followed quickly by the next prompt
|
|
1404
|
+
// would report the cancelled turn as a normal completion.
|
|
1405
|
+
settleActive(session.cancelled
|
|
1406
|
+
? { stopReason: "cancelled" }
|
|
1407
|
+
: { stopReason: "end_turn", usage: sessionUsage(session) });
|
|
1408
|
+
}
|
|
1409
|
+
activateTurn(queued);
|
|
1410
|
+
}
|
|
1019
1411
|
break;
|
|
1020
1412
|
}
|
|
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
1413
|
if ("isReplay" in message && message.isReplay) {
|
|
1031
|
-
//
|
|
1414
|
+
// Unrelated replay (e.g. the echo of an already-settled turn).
|
|
1032
1415
|
break;
|
|
1033
1416
|
}
|
|
1034
1417
|
}
|
|
1418
|
+
if (session.cancelled) {
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1035
1421
|
// Snapshot the latest top-level assistant usage and model so the
|
|
1036
1422
|
// next `result` can emit a usage_update tied to the right context
|
|
1037
1423
|
// window. Subagent messages are excluded to keep the snapshot
|
|
@@ -1096,37 +1482,70 @@ export class ClaudeAcpAgent {
|
|
|
1096
1482
|
message.message.content.length === 1 &&
|
|
1097
1483
|
message.message.content[0].type === "text" &&
|
|
1098
1484
|
message.message.content[0].text.includes("Please run /login")) {
|
|
1099
|
-
|
|
1485
|
+
failActive(RequestError.authRequired());
|
|
1486
|
+
break;
|
|
1100
1487
|
}
|
|
1101
1488
|
let content;
|
|
1102
1489
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
1103
|
-
// Top-level assistant message:
|
|
1104
|
-
// streamed live as
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1490
|
+
// Top-level assistant message: each text/thinking block may have
|
|
1491
|
+
// already been streamed live as deltas. Diff each against what
|
|
1492
|
+
// streamed (`streamedBlocks`, in document order) and forward only
|
|
1493
|
+
// the un-streamed remainder — nothing if it streamed in full (the
|
|
1494
|
+
// common case), the whole block if it never streamed (a
|
|
1495
|
+
// non-streaming gateway), or just the tail if the stream was cut
|
|
1496
|
+
// short mid-block. `streamPos` walks the streamed blocks in step
|
|
1497
|
+
// with the assembled text/thinking blocks; tool_use and other
|
|
1498
|
+
// blocks pass through untouched (their own `toolUseCache` collapses
|
|
1499
|
+
// the streamed/assembled pair) without advancing it.
|
|
1500
|
+
const blocks = message.message.content;
|
|
1501
|
+
const kept = [];
|
|
1502
|
+
let streamPos = 0;
|
|
1503
|
+
for (const item of blocks) {
|
|
1110
1504
|
if (item.type !== "text" && item.type !== "thinking") {
|
|
1111
|
-
|
|
1505
|
+
kept.push(item);
|
|
1506
|
+
continue;
|
|
1112
1507
|
}
|
|
1113
|
-
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
if (streamedLive) {
|
|
1119
|
-
return false;
|
|
1508
|
+
const full = item.type === "text" ? item.text : item.thinking;
|
|
1509
|
+
// Empty assembled blocks carry nothing (some gateways emit an
|
|
1510
|
+
// empty `thinking` block before the real text) — drop them.
|
|
1511
|
+
if (full.length === 0) {
|
|
1512
|
+
continue;
|
|
1120
1513
|
}
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1514
|
+
// A streamed block of the same type whose accumulated text is a
|
|
1515
|
+
// prefix of this one was already (at least partly) delivered as
|
|
1516
|
+
// chunks; consume it and forward only what's left. A non-empty
|
|
1517
|
+
// streamed text is required so an empty/aborted streamed block
|
|
1518
|
+
// doesn't swallow the assembled copy.
|
|
1519
|
+
const streamed = streamedBlocks[streamPos];
|
|
1520
|
+
if (streamed &&
|
|
1521
|
+
streamed.type === item.type &&
|
|
1522
|
+
streamed.text.length > 0 &&
|
|
1523
|
+
full.startsWith(streamed.text)) {
|
|
1524
|
+
streamPos++;
|
|
1525
|
+
const remainder = full.slice(streamed.text.length);
|
|
1526
|
+
if (remainder.length === 0) {
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
// Overwrite in place with just the un-streamed tail (the
|
|
1530
|
+
// assembled message isn't read again after this) so the block
|
|
1531
|
+
// keeps its exact SDK type.
|
|
1532
|
+
if (item.type === "text") {
|
|
1533
|
+
item.text = remainder;
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
item.thinking = remainder;
|
|
1537
|
+
}
|
|
1538
|
+
kept.push(item);
|
|
1539
|
+
continue;
|
|
1127
1540
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1541
|
+
// Not matched: never streamed (or the stream diverged from the
|
|
1542
|
+
// assembled text) — forward the block in full.
|
|
1543
|
+
kept.push(item);
|
|
1544
|
+
}
|
|
1545
|
+
content = kept;
|
|
1546
|
+
// Consumed: reset so the next message's blocks accumulate fresh and
|
|
1547
|
+
// the record stays bounded to the in-flight message.
|
|
1548
|
+
streamedBlocks.length = 0;
|
|
1130
1549
|
}
|
|
1131
1550
|
else if (message.type === "assistant") {
|
|
1132
1551
|
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
|
@@ -1189,83 +1608,36 @@ export class ClaudeAcpAgent {
|
|
|
1189
1608
|
break;
|
|
1190
1609
|
}
|
|
1191
1610
|
}
|
|
1192
|
-
|
|
1611
|
+
// `while (true)` only exits via the `done` return above or the catch
|
|
1612
|
+
// below, so there is no normal fall-through here.
|
|
1193
1613
|
}
|
|
1194
1614
|
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")) {
|
|
1615
|
+
// The query stream itself died (a transport/process error surfaced from
|
|
1616
|
+
// query.next()). Turn-level failures (auth, error results) are handled
|
|
1617
|
+
// inline via failActive and never reach here. Reject every in-flight turn;
|
|
1618
|
+
// if the process is gone, tear the session down so the client starts fresh.
|
|
1619
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1620
|
+
const processDied = error instanceof Error &&
|
|
1621
|
+
(message.includes("ProcessTransport") ||
|
|
1622
|
+
message.includes("terminated process") ||
|
|
1623
|
+
message.includes("process exited with") ||
|
|
1624
|
+
message.includes("process terminated by signal") ||
|
|
1625
|
+
message.includes("Failed to write to process stdin"));
|
|
1626
|
+
// Either way the query iterator is finished and the consumer is exiting,
|
|
1627
|
+
// so release its resources via closeQueryStream (idempotent). A process
|
|
1628
|
+
// death is unrecoverable, so additionally evict the session so the client
|
|
1629
|
+
// starts fresh; other stream errors keep the session so prompt()/cancel()
|
|
1630
|
+
// can answer with a clear "session ended" error.
|
|
1631
|
+
if (processDied) {
|
|
1228
1632
|
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
1229
|
-
session.
|
|
1230
|
-
|
|
1633
|
+
failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
|
|
1634
|
+
this.closeQueryStream(session);
|
|
1231
1635
|
delete this.sessions[params.sessionId];
|
|
1232
|
-
throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
|
|
1233
|
-
}
|
|
1234
|
-
throw error;
|
|
1235
|
-
}
|
|
1236
|
-
finally {
|
|
1237
|
-
// The loop is returning — interrupt() succeeded or the prompt finished
|
|
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
1636
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
}
|
|
1637
|
+
else {
|
|
1638
|
+
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
1639
|
+
failAllTurns(error);
|
|
1640
|
+
this.closeQueryStream(session);
|
|
1269
1641
|
}
|
|
1270
1642
|
}
|
|
1271
1643
|
}
|
|
@@ -1274,23 +1646,46 @@ export class ClaudeAcpAgent {
|
|
|
1274
1646
|
if (!session) {
|
|
1275
1647
|
return;
|
|
1276
1648
|
}
|
|
1649
|
+
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
1650
|
+
// settled when it closed, and there is no live query to interrupt. Calling
|
|
1651
|
+
// query.interrupt() on a finished iterator could reject and surface from
|
|
1652
|
+
// this fire-and-forget notification, so there is nothing to do here.
|
|
1653
|
+
if (session.queryClosed) {
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1277
1656
|
session.cancelled = true;
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1657
|
+
// Settle queued turns that haven't started yet (no echo seen) right away —
|
|
1658
|
+
// they have no in-flight SDK work to interrupt. The active turn is settled
|
|
1659
|
+
// by the consumer when it observes the interrupt's trailing idle (or via the
|
|
1660
|
+
// backstop below). Mirrors the old pendingMessages cancellation.
|
|
1661
|
+
if (session.turnQueue) {
|
|
1662
|
+
let orphaned = 0;
|
|
1663
|
+
for (const turn of session.turnQueue) {
|
|
1664
|
+
if (turn !== session.activeTurn && !turn.settled) {
|
|
1665
|
+
turn.settled = true;
|
|
1666
|
+
turn.resolve({ stopReason: "cancelled" });
|
|
1667
|
+
orphaned++;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Each removed queued turn's user message was already pushed to the SDK,
|
|
1671
|
+
// which processes input FIFO and will still emit a result for it with no
|
|
1672
|
+
// uuid to match. Count those so the consumer skips them (see
|
|
1673
|
+
// ensureActiveTurn) rather than misattributing them to the head.
|
|
1674
|
+
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
|
|
1675
|
+
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
1676
|
+
}
|
|
1677
|
+
// Arm a backstop before interrupting: if a turn is actively consuming the
|
|
1678
|
+
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
|
1679
|
+
// block — issue #680), force the consumer to settle the active turn
|
|
1680
|
+
// "cancelled" after the floor elapses so the pending session/prompt still
|
|
1681
|
+
// resolves per the ACP cancellation contract instead of hanging forever. The
|
|
1682
|
+
// consumer clears this timer when interrupt() works and it settles through
|
|
1288
1683
|
// the normal idle path, so on healthy cancels it is armed but never fires.
|
|
1289
1684
|
//
|
|
1290
|
-
// Arm at most once per turn: the floor is an absolute ceiling from the
|
|
1291
|
-
//
|
|
1685
|
+
// Arm at most once per turn: the floor is an absolute ceiling from the first
|
|
1686
|
+
// cancel, so a client that re-sends cancel (each call still retries
|
|
1292
1687
|
// interrupt() below) can't keep pushing the deadline out.
|
|
1293
|
-
if (session.
|
|
1688
|
+
if (session.activeTurn &&
|
|
1294
1689
|
session.cancelController &&
|
|
1295
1690
|
!session.cancelController.signal.aborted &&
|
|
1296
1691
|
!session.forceCancelTimer) {
|
|
@@ -1302,8 +1697,37 @@ export class ClaudeAcpAgent {
|
|
|
1302
1697
|
}
|
|
1303
1698
|
await session.query.interrupt();
|
|
1304
1699
|
}
|
|
1305
|
-
/**
|
|
1306
|
-
*
|
|
1700
|
+
/** Mark a session's SDK query stream as permanently ended and release the
|
|
1701
|
+
* resources tied to it: drop the consumer handle, dispose the settings
|
|
1702
|
+
* watchers, end the input stream, and close the query (which terminates the
|
|
1703
|
+
* subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
|
|
1704
|
+
* consult `queryClosed` and fail/short-circuit instead of acting on a dead
|
|
1705
|
+
* stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
|
|
1706
|
+
* paths and a later `teardownSession` can all call it without double-releasing.
|
|
1707
|
+
*
|
|
1708
|
+
* Deliberately does NOT abort `session.abortController`: that controller may be
|
|
1709
|
+
* CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
|
|
1710
|
+
* aborting it on a spontaneous stream end would cancel the client's own work
|
|
1711
|
+
* or make a sibling session born aborted. `query.close()` already terminates
|
|
1712
|
+
* the subprocess; aborting the signal belongs in `teardownSession` (explicit
|
|
1713
|
+
* destroy), not here. Also does NOT remove the session from the map — that is
|
|
1714
|
+
* `teardownSession`'s job — so prompt() can still answer with a clear "session
|
|
1715
|
+
* ended" error after an unexpected stream close. The leftover session object
|
|
1716
|
+
* is a lightweight husk (its heavy resources are released here) and is evicted
|
|
1717
|
+
* on the next closeSession/deleteSession or when the connection's `dispose()`
|
|
1718
|
+
* runs. */
|
|
1719
|
+
closeQueryStream(session) {
|
|
1720
|
+
if (session.queryClosed) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
session.queryClosed = true;
|
|
1724
|
+
session.consumer = undefined;
|
|
1725
|
+
session.settingsManager.dispose();
|
|
1726
|
+
session.input.end();
|
|
1727
|
+
session.query.close();
|
|
1728
|
+
}
|
|
1729
|
+
/** Cleanly tear down a session: cancel in-flight work, release stream
|
|
1730
|
+
* resources, and remove it from the session map. */
|
|
1307
1731
|
async teardownSession(sessionId) {
|
|
1308
1732
|
const session = this.sessions[sessionId];
|
|
1309
1733
|
if (!session) {
|
|
@@ -1311,20 +1735,23 @@ export class ClaudeAcpAgent {
|
|
|
1311
1735
|
}
|
|
1312
1736
|
await this.cancel({ sessionId });
|
|
1313
1737
|
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
1314
|
-
// wedged
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1738
|
+
// wedged consumer only wakes when `cancelController` aborts — closeQueryStream
|
|
1739
|
+
// below doesn't touch it. Since we're tearing the session down anyway, wake
|
|
1740
|
+
// the consumer now so the in-flight prompt() resolves immediately instead of
|
|
1741
|
+
// after the floor, and clear the timer so it can't outlive the deleted
|
|
1742
|
+
// session (it isn't unref'd and would otherwise keep the event loop alive
|
|
1743
|
+
// until it fires).
|
|
1320
1744
|
if (session.forceCancelTimer) {
|
|
1321
1745
|
clearTimeout(session.forceCancelTimer);
|
|
1322
1746
|
session.forceCancelTimer = undefined;
|
|
1323
1747
|
}
|
|
1324
1748
|
session.cancelController?.abort();
|
|
1325
|
-
|
|
1749
|
+
this.closeQueryStream(session);
|
|
1750
|
+
// Abort the SDK abort signal only on explicit destroy. closeQueryStream
|
|
1751
|
+
// leaves it alone (it may be a client-owned controller — see its doc), but
|
|
1752
|
+
// here the client has asked us to close the session, so signalling abort is
|
|
1753
|
+
// appropriate; query.close() above has already torn the subprocess down.
|
|
1326
1754
|
session.abortController.abort();
|
|
1327
|
-
session.query.close();
|
|
1328
1755
|
delete this.sessions[sessionId];
|
|
1329
1756
|
}
|
|
1330
1757
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
@@ -1348,9 +1775,16 @@ export class ClaudeAcpAgent {
|
|
|
1348
1775
|
return {};
|
|
1349
1776
|
}
|
|
1350
1777
|
async setSessionMode(params) {
|
|
1351
|
-
|
|
1778
|
+
const session = this.sessions[params.sessionId];
|
|
1779
|
+
if (!session) {
|
|
1352
1780
|
throw new Error("Session not found");
|
|
1353
1781
|
}
|
|
1782
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1783
|
+
// a husk and `query.setPermissionMode` below would act on a closed query.
|
|
1784
|
+
// Fail with the same clear message prompt()/cancel() give for a dead stream.
|
|
1785
|
+
if (session.queryClosed) {
|
|
1786
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1787
|
+
}
|
|
1354
1788
|
await this.applySessionMode(params.sessionId, params.modeId);
|
|
1355
1789
|
await this.updateConfigOption(params.sessionId, "mode", params.modeId);
|
|
1356
1790
|
return {};
|
|
@@ -1360,6 +1794,13 @@ export class ClaudeAcpAgent {
|
|
|
1360
1794
|
if (!session) {
|
|
1361
1795
|
throw new Error("Session not found");
|
|
1362
1796
|
}
|
|
1797
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1798
|
+
// a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
|
|
1799
|
+
// calls this triggers would act on a closed query. Fail with the same clear
|
|
1800
|
+
// message prompt()/cancel() give for a dead stream.
|
|
1801
|
+
if (session.queryClosed) {
|
|
1802
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1803
|
+
}
|
|
1363
1804
|
if (typeof params.value !== "string") {
|
|
1364
1805
|
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
1365
1806
|
}
|
|
@@ -1489,6 +1930,24 @@ export class ClaudeAcpAgent {
|
|
|
1489
1930
|
const response = await this.client.writeTextFile(params);
|
|
1490
1931
|
return response;
|
|
1491
1932
|
}
|
|
1933
|
+
/** Forward a permission request to the client, wiring the tool call's
|
|
1934
|
+
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
1935
|
+
* while the client's prompt is still open the signal aborts, the SDK sends
|
|
1936
|
+
* `$/cancel_request`, and the client settles the request (a `cancelled`
|
|
1937
|
+
* outcome or a `requestCancelled` rejection). Either way we surface the same
|
|
1938
|
+
* "Tool use aborted" the callers already expect, so a cancelled dialog no
|
|
1939
|
+
* longer leaves the `await` hanging. */
|
|
1940
|
+
async requestPermissionFromClient(params, signal) {
|
|
1941
|
+
try {
|
|
1942
|
+
return await this.client.requestPermission(params, signal);
|
|
1943
|
+
}
|
|
1944
|
+
catch (error) {
|
|
1945
|
+
if (signal.aborted) {
|
|
1946
|
+
throw new Error("Tool use aborted", { cause: error });
|
|
1947
|
+
}
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1492
1951
|
canUseTool(sessionId) {
|
|
1493
1952
|
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
1494
1953
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
@@ -1531,7 +1990,7 @@ export class ClaudeAcpAgent {
|
|
|
1531
1990
|
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
|
|
1532
1991
|
// "keep planning" reject path; it's always present in `availableModes`.
|
|
1533
1992
|
const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
|
|
1534
|
-
const response = await this.
|
|
1993
|
+
const response = await this.requestPermissionFromClient({
|
|
1535
1994
|
options,
|
|
1536
1995
|
sessionId,
|
|
1537
1996
|
toolCall: {
|
|
@@ -1539,7 +1998,7 @@ export class ClaudeAcpAgent {
|
|
|
1539
1998
|
rawInput: toolInput,
|
|
1540
1999
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
1541
2000
|
},
|
|
1542
|
-
});
|
|
2001
|
+
}, signal);
|
|
1543
2002
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
1544
2003
|
throw new Error("Tool use aborted");
|
|
1545
2004
|
}
|
|
@@ -1582,7 +2041,7 @@ export class ClaudeAcpAgent {
|
|
|
1582
2041
|
],
|
|
1583
2042
|
};
|
|
1584
2043
|
}
|
|
1585
|
-
const response = await this.
|
|
2044
|
+
const response = await this.requestPermissionFromClient({
|
|
1586
2045
|
options: [
|
|
1587
2046
|
{
|
|
1588
2047
|
kind: "allow_always",
|
|
@@ -1598,7 +2057,7 @@ export class ClaudeAcpAgent {
|
|
|
1598
2057
|
rawInput: toolInput,
|
|
1599
2058
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
1600
2059
|
},
|
|
1601
|
-
});
|
|
2060
|
+
}, signal);
|
|
1602
2061
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
1603
2062
|
throw new Error("Tool use aborted");
|
|
1604
2063
|
}
|
|
@@ -1648,13 +2107,18 @@ export class ClaudeAcpAgent {
|
|
|
1648
2107
|
return { action: "decline" };
|
|
1649
2108
|
}
|
|
1650
2109
|
try {
|
|
1651
|
-
const response = await this.client.unstable_createElicitation(createRequest);
|
|
2110
|
+
const response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
1652
2111
|
if (signal.aborted) {
|
|
1653
2112
|
return { action: "cancel" };
|
|
1654
2113
|
}
|
|
1655
2114
|
return createElicitationResponseToElicitResult(response);
|
|
1656
2115
|
}
|
|
1657
2116
|
catch (error) {
|
|
2117
|
+
// A cancellation we requested (signal aborted) settles as a cancel, not
|
|
2118
|
+
// a hard decline — the elicitation was abandoned, not refused.
|
|
2119
|
+
if (signal.aborted) {
|
|
2120
|
+
return { action: "cancel" };
|
|
2121
|
+
}
|
|
1658
2122
|
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
|
1659
2123
|
return { action: "decline" };
|
|
1660
2124
|
}
|
|
@@ -1673,9 +2137,14 @@ export class ClaudeAcpAgent {
|
|
|
1673
2137
|
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
1674
2138
|
let response;
|
|
1675
2139
|
try {
|
|
1676
|
-
response = await this.client.unstable_createElicitation(createRequest);
|
|
2140
|
+
response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
1677
2141
|
}
|
|
1678
2142
|
catch (error) {
|
|
2143
|
+
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
2144
|
+
// use, matching the post-response check below.
|
|
2145
|
+
if (signal.aborted) {
|
|
2146
|
+
throw new Error("Tool use aborted", { cause: error });
|
|
2147
|
+
}
|
|
1679
2148
|
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
|
1680
2149
|
return { behavior: "deny", message: "Could not present the question to the user." };
|
|
1681
2150
|
}
|
|
@@ -1720,18 +2189,22 @@ export class ClaudeAcpAgent {
|
|
|
1720
2189
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1721
2190
|
}
|
|
1722
2191
|
else if (configId === "model") {
|
|
2192
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
|
|
2193
|
+
// modes below; its `displayName`/`description` also let us infer the
|
|
2194
|
+
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
2195
|
+
// carries no "1m" token.
|
|
2196
|
+
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
1723
2197
|
if (session.models.currentModelId !== value) {
|
|
1724
2198
|
// The cached context window was learned for the previous model; reset
|
|
1725
2199
|
// to the new model's heuristic so mid-stream updates between now and
|
|
1726
2200
|
// the next `result` reflect the user's selection instead of the old
|
|
1727
2201
|
// model's window.
|
|
1728
|
-
session.contextWindowSize =
|
|
2202
|
+
session.contextWindowSize =
|
|
2203
|
+
inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
|
|
1729
2204
|
}
|
|
1730
2205
|
session.models = { ...session.models, currentModelId: value };
|
|
1731
2206
|
// Recompute availableModes for the new model and clamp the current
|
|
1732
2207
|
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
|
1733
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
|
|
1734
|
-
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
1735
2208
|
const newAvailableModes = buildAvailableModes(newModelInfo);
|
|
1736
2209
|
// Capture BEFORE mutating session.modes so the log message reflects
|
|
1737
2210
|
// the invalidated mode rather than "default".
|
|
@@ -1760,7 +2233,7 @@ export class ClaudeAcpAgent {
|
|
|
1760
2233
|
// Rebuild config options since effort levels depend on the selected model
|
|
1761
2234
|
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1762
2235
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
1763
|
-
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort);
|
|
2236
|
+
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent);
|
|
1764
2237
|
// Sync effort with the SDK if it changed after the model switch
|
|
1765
2238
|
const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1766
2239
|
const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
|
@@ -1785,6 +2258,18 @@ export class ClaudeAcpAgent {
|
|
|
1785
2258
|
});
|
|
1786
2259
|
}
|
|
1787
2260
|
}
|
|
2261
|
+
else if (configId === "agent") {
|
|
2262
|
+
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
|
2263
|
+
// first so a rejected control request leaves both `currentAgent` and the
|
|
2264
|
+
// config option untouched (no UI/SDK desync). Passing `null` clears the
|
|
2265
|
+
// flag layer back to the standard Claude Code agent; the change takes
|
|
2266
|
+
// effect on the next turn (SDK >= 0.3.161).
|
|
2267
|
+
await session.query.applyFlagSettings({
|
|
2268
|
+
agent: value === DEFAULT_AGENT_ID ? null : value,
|
|
2269
|
+
});
|
|
2270
|
+
session.currentAgent = value;
|
|
2271
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
2272
|
+
}
|
|
1788
2273
|
else {
|
|
1789
2274
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1790
2275
|
if (configId === "effort") {
|
|
@@ -2133,7 +2618,17 @@ export class ClaudeAcpAgent {
|
|
|
2133
2618
|
currentModeId: effectiveMode,
|
|
2134
2619
|
availableModes,
|
|
2135
2620
|
};
|
|
2136
|
-
const
|
|
2621
|
+
const agents = await discoverCustomAgents(q);
|
|
2622
|
+
// Only adopt the requested agent as the selected value if it's one we
|
|
2623
|
+
// actually surface in the picker. A built-in (filtered out above) or
|
|
2624
|
+
// otherwise-unknown name would leave the config option's `currentValue`
|
|
2625
|
+
// pointing at an entry not in its own `options` list, which clients render
|
|
2626
|
+
// as a blank/invalid selection.
|
|
2627
|
+
const requestedAgent = userProvidedOptions?.agent;
|
|
2628
|
+
const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
|
|
2629
|
+
? requestedAgent
|
|
2630
|
+
: DEFAULT_AGENT_ID;
|
|
2631
|
+
const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent);
|
|
2137
2632
|
// Apply the initial effort level to the SDK so it matches the UI default
|
|
2138
2633
|
const initialEffort = configOptions.find((o) => o.id === "effort");
|
|
2139
2634
|
if (initialEffort &&
|
|
@@ -2160,12 +2655,11 @@ export class ClaudeAcpAgent {
|
|
|
2160
2655
|
models,
|
|
2161
2656
|
modelInfos: allowedModels,
|
|
2162
2657
|
configOptions,
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
nextPendingOrder: 0,
|
|
2658
|
+
agents,
|
|
2659
|
+
currentAgent,
|
|
2166
2660
|
abortController,
|
|
2167
2661
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
2168
|
-
contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
|
2662
|
+
contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
|
|
2169
2663
|
taskState,
|
|
2170
2664
|
toolUseCache: {},
|
|
2171
2665
|
messageIdToUuid: new Map(),
|
|
@@ -2309,7 +2803,39 @@ function buildAvailableModes(modelInfo) {
|
|
|
2309
2803
|
function toSdkEffortLevel(value) {
|
|
2310
2804
|
return value === undefined || value === "default" ? null : value;
|
|
2311
2805
|
}
|
|
2312
|
-
|
|
2806
|
+
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
|
2807
|
+
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
|
2808
|
+
// has configured none of their own. Those aren't meaningful *main-thread*
|
|
2809
|
+
// personas, so we filter them out and only surface the Agent picker when the
|
|
2810
|
+
// user (or a plugin/project) has configured custom agents. Update this set if
|
|
2811
|
+
// the SDK's built-in roster changes.
|
|
2812
|
+
export const BUILTIN_AGENT_NAMES = new Set([
|
|
2813
|
+
"claude",
|
|
2814
|
+
"general-purpose",
|
|
2815
|
+
"Explore",
|
|
2816
|
+
"Plan",
|
|
2817
|
+
"statusline-setup",
|
|
2818
|
+
]);
|
|
2819
|
+
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
|
2820
|
+
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
|
2821
|
+
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
2822
|
+
// (two options sharing the value, selection silently routing to `null`), so we
|
|
2823
|
+
// exclude that name from discovery.
|
|
2824
|
+
export const DEFAULT_AGENT_ID = "default";
|
|
2825
|
+
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
2826
|
+
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
2827
|
+
* list if discovery fails so a flaky control request never blocks session
|
|
2828
|
+
* creation. */
|
|
2829
|
+
export async function discoverCustomAgents(q) {
|
|
2830
|
+
try {
|
|
2831
|
+
const agents = await q.supportedAgents();
|
|
2832
|
+
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
return [];
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID) {
|
|
2313
2839
|
const options = [
|
|
2314
2840
|
{
|
|
2315
2841
|
id: "mode",
|
|
@@ -2366,6 +2892,27 @@ function buildConfigOptions(modes, models, modelInfos, currentEffortLevel) {
|
|
|
2366
2892
|
options: effortOptions,
|
|
2367
2893
|
});
|
|
2368
2894
|
}
|
|
2895
|
+
// Only surface the Agent picker when there's a real choice — i.e. the user
|
|
2896
|
+
// has configured at least one custom agent (built-ins are filtered out in
|
|
2897
|
+
// discoverCustomAgents). With none configured, "Default" would be the only
|
|
2898
|
+
// entry, so we omit the option entirely.
|
|
2899
|
+
if (agents.length > 0) {
|
|
2900
|
+
options.push({
|
|
2901
|
+
id: "agent",
|
|
2902
|
+
name: "Agent",
|
|
2903
|
+
description: "Main-thread agent persona",
|
|
2904
|
+
type: "select",
|
|
2905
|
+
currentValue: currentAgent,
|
|
2906
|
+
options: [
|
|
2907
|
+
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
|
2908
|
+
...agents.map((a) => ({
|
|
2909
|
+
value: a.name,
|
|
2910
|
+
name: a.name,
|
|
2911
|
+
description: a.description || undefined,
|
|
2912
|
+
})),
|
|
2913
|
+
],
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2369
2916
|
return options;
|
|
2370
2917
|
}
|
|
2371
2918
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
@@ -2512,6 +3059,20 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
|
2512
3059
|
}
|
|
2513
3060
|
seen.add(trimmed);
|
|
2514
3061
|
}
|
|
3062
|
+
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
|
3063
|
+
// allowlist, the same way Default is. Per the model-config docs it adds an
|
|
3064
|
+
// entry "without replacing the built-in aliases" and "appears at the bottom of
|
|
3065
|
+
// the /model picker", so we append it last and skip the allowlist filter; this
|
|
3066
|
+
// keeps a slim alias allowlist from hiding the custom model row.
|
|
3067
|
+
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
|
3068
|
+
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
|
|
3069
|
+
if (customModelOption && !seen.has(customModelOption)) {
|
|
3070
|
+
const customModel = sdkModels.find((m) => m.value === customModelOption);
|
|
3071
|
+
if (customModel) {
|
|
3072
|
+
result.push(customModel);
|
|
3073
|
+
seen.add(customModel.value);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
2515
3076
|
return result;
|
|
2516
3077
|
}
|
|
2517
3078
|
async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
|
|
@@ -2779,13 +3340,17 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2779
3340
|
break;
|
|
2780
3341
|
case "thinking":
|
|
2781
3342
|
case "thinking_delta":
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
3343
|
+
// Recent models default `thinking.display` to "omitted", which streams
|
|
3344
|
+
// signature-only thinking blocks whose text is empty.
|
|
3345
|
+
if (chunk.thinking.length > 0) {
|
|
3346
|
+
update = {
|
|
3347
|
+
sessionUpdate: "agent_thought_chunk",
|
|
3348
|
+
content: {
|
|
3349
|
+
type: "text",
|
|
3350
|
+
text: chunk.thinking,
|
|
3351
|
+
},
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
2789
3354
|
break;
|
|
2790
3355
|
case "tool_use":
|
|
2791
3356
|
case "server_tool_use":
|
|
@@ -3042,15 +3607,63 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
3042
3607
|
return [];
|
|
3043
3608
|
}
|
|
3044
3609
|
}
|
|
3610
|
+
/** Run a `session/prompt` while honoring `$/cancel_request` for it. ACP clients
|
|
3611
|
+
* normally stop a turn with the `session/cancel` notification, but `signal`
|
|
3612
|
+
* (the prompt request's abort signal) also fires when the client sends the
|
|
3613
|
+
* generic `$/cancel_request` for this prompt — the protocol's complementary
|
|
3614
|
+
* cancellation fallback. Route that to the same `agent.cancel` path so a client
|
|
3615
|
+
* using only the generic mechanism still stops the turn (and the prompt
|
|
3616
|
+
* resolves "cancelled" instead of running to completion).
|
|
3617
|
+
*
|
|
3618
|
+
* The listener is scoped to this call: once the prompt settles it is removed,
|
|
3619
|
+
* so a later teardown-time abort of the (per-request) signal can't cancel a
|
|
3620
|
+
* subsequent turn. `signal` also aborts on connection close, in which case
|
|
3621
|
+
* cancelling the in-flight turn is the desired behavior anyway. */
|
|
3622
|
+
export async function runPromptWithCancellation(agent, params, signal) {
|
|
3623
|
+
const onAbort = () => {
|
|
3624
|
+
// Fire-and-forget: nothing awaits this listener, so swallow (and log) any
|
|
3625
|
+
// rejection rather than surfacing it as an unhandled rejection.
|
|
3626
|
+
agent.cancel({ sessionId: params.sessionId }).catch((error) => {
|
|
3627
|
+
agent.logger.error(`Failed to cancel prompt via $/cancel_request: ${error}`);
|
|
3628
|
+
});
|
|
3629
|
+
};
|
|
3630
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3631
|
+
try {
|
|
3632
|
+
return await agent.prompt(params);
|
|
3633
|
+
}
|
|
3634
|
+
finally {
|
|
3635
|
+
signal.removeEventListener("abort", onAbort);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3045
3638
|
export function runAcp() {
|
|
3046
3639
|
const input = nodeToWebWritable(process.stdout);
|
|
3047
3640
|
const output = nodeToWebReadable(process.stdin);
|
|
3048
3641
|
const stream = ndJsonStream(input, output);
|
|
3642
|
+
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
|
|
3643
|
+
// that stays valid for the whole connection, so the agent captures it once.
|
|
3644
|
+
// Handlers close over `agent`, which is assigned synchronously right after
|
|
3645
|
+
// `connect()` returns — before the connection processes any inbound message.
|
|
3646
|
+
// It cannot be `const`: its value depends on `connection.client`, which does
|
|
3647
|
+
// not exist until `connect()` has been called.
|
|
3648
|
+
// eslint-disable-next-line prefer-const
|
|
3049
3649
|
let agent;
|
|
3050
|
-
const connection =
|
|
3051
|
-
agent
|
|
3052
|
-
|
|
3053
|
-
|
|
3650
|
+
const connection = acpAgent({ name: "claude-code-acp" })
|
|
3651
|
+
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
|
3652
|
+
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
|
3653
|
+
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
|
|
3654
|
+
.onRequest(methods.agent.session.fork, (ctx) => agent.unstable_forkSession(ctx.params))
|
|
3655
|
+
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
|
|
3656
|
+
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
|
|
3657
|
+
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
|
|
3658
|
+
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
|
|
3659
|
+
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
|
3660
|
+
.onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
|
|
3661
|
+
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
|
3662
|
+
.onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
|
|
3663
|
+
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
3664
|
+
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
3665
|
+
.connect(stream);
|
|
3666
|
+
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
3054
3667
|
return { connection, agent };
|
|
3055
3668
|
}
|
|
3056
3669
|
function commonPrefixLength(a, b) {
|
|
@@ -3060,14 +3673,21 @@ function commonPrefixLength(a, b) {
|
|
|
3060
3673
|
}
|
|
3061
3674
|
return i;
|
|
3062
3675
|
}
|
|
3063
|
-
/** Best-effort first guess of a model's context window
|
|
3064
|
-
*
|
|
3065
|
-
*
|
|
3676
|
+
/** Best-effort first guess of a model's context window, used only as a
|
|
3677
|
+
* fallback when the SDK's authoritative `getContextUsage` is unavailable (and
|
|
3678
|
+
* until a `result` message arrives with the `modelUsage` value).
|
|
3679
|
+
*
|
|
3066
3680
|
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
3067
3681
|
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
3068
|
-
* matching things like "10m" or embedded substrings.
|
|
3069
|
-
|
|
3070
|
-
|
|
3682
|
+
* matching things like "10m" or embedded substrings. Semantic aliases like
|
|
3683
|
+
* `default` carry no such token in the ID, but the SDK's human-facing
|
|
3684
|
+
* `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
|
|
3685
|
+
* pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
|
|
3686
|
+
* `ModelInfo` exposes no structured context-window field, so this text scan is
|
|
3687
|
+
* the only pre-`result` signal available. A miss falls back to the default
|
|
3688
|
+
* window and is corrected by `result.modelUsage` within one turn. */
|
|
3689
|
+
function inferContextWindowFromModel(...texts) {
|
|
3690
|
+
if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
|
|
3071
3691
|
return 1_000_000;
|
|
3072
3692
|
return null;
|
|
3073
3693
|
}
|