@lucascouts/claude-agent-acp-plus 0.8.0 → 0.9.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 +107 -95
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1050 -818
- package/dist/clear-context-coordinator.d.ts +58 -0
- package/dist/clear-context-coordinator.d.ts.map +1 -0
- package/dist/clear-context-coordinator.js +79 -0
- package/dist/exit-plan.d.ts +42 -0
- package/dist/exit-plan.d.ts.map +1 -0
- package/dist/exit-plan.js +154 -0
- package/dist/file-change-audit.d.ts +56 -0
- package/dist/file-change-audit.d.ts.map +1 -0
- package/dist/file-change-audit.js +379 -0
- package/dist/index.js +24 -1
- package/dist/permissions/effects.d.ts +19 -0
- package/dist/permissions/effects.d.ts.map +1 -0
- package/dist/permissions/effects.js +166 -0
- package/dist/permissions/modes.d.ts +8 -0
- package/dist/permissions/modes.d.ts.map +1 -0
- package/dist/permissions/modes.js +41 -0
- package/dist/permissions/normalization.d.ts +6 -0
- package/dist/permissions/normalization.d.ts.map +1 -0
- package/dist/permissions/normalization.js +100 -0
- package/dist/permissions/options/filesystem.d.ts +9 -0
- package/dist/permissions/options/filesystem.d.ts.map +1 -0
- package/dist/permissions/options/filesystem.js +124 -0
- package/dist/permissions/options/shared.d.ts +39 -0
- package/dist/permissions/options/shared.d.ts.map +1 -0
- package/dist/permissions/options/shared.js +64 -0
- package/dist/permissions/options/shell.d.ts +5 -0
- package/dist/permissions/options/shell.d.ts.map +1 -0
- package/dist/permissions/options/shell.js +100 -0
- package/dist/permissions/options/tools.d.ts +11 -0
- package/dist/permissions/options/tools.d.ts.map +1 -0
- package/dist/permissions/options/tools.js +135 -0
- package/dist/permissions/options.d.ts +5 -0
- package/dist/permissions/options.d.ts.map +1 -0
- package/dist/permissions/options.js +60 -0
- package/dist/permissions/presentation.d.ts +15 -0
- package/dist/permissions/presentation.d.ts.map +1 -0
- package/dist/permissions/presentation.js +83 -0
- package/dist/permissions/response.d.ts +11 -0
- package/dist/permissions/response.d.ts.map +1 -0
- package/dist/permissions/response.js +19 -0
- package/dist/session-config-ids.d.ts +5 -0
- package/dist/session-config-ids.d.ts.map +1 -0
- package/dist/session-config-ids.js +4 -0
- package/dist/session-failure-extension.d.ts +104 -0
- package/dist/session-failure-extension.d.ts.map +1 -0
- package/dist/session-failure-extension.js +352 -0
- package/dist/session-mode.d.ts +66 -0
- package/dist/session-mode.d.ts.map +1 -0
- package/dist/session-mode.js +246 -0
- package/dist/session-titles.d.ts +77 -0
- package/dist/session-titles.d.ts.map +1 -0
- package/dist/session-titles.js +199 -0
- package/dist/tool-result-meta.d.ts +8 -0
- package/dist/tool-result-meta.d.ts.map +1 -0
- package/dist/tool-result-meta.js +19 -0
- package/dist/tools.d.ts +4 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +129 -24
- package/package.json +9 -9
package/dist/acp-agent.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
-
import { deleteSession,
|
|
2
|
+
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
|
|
4
|
+
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
6
8
|
import * as fs from "node:fs/promises";
|
|
7
9
|
import * as os from "node:os";
|
|
8
10
|
import * as path from "node:path";
|
|
@@ -14,22 +16,23 @@ import { filterDeprecatedModels } from "./model-deprecation.js";
|
|
|
14
16
|
import { SettingsManager } from "./settings.js";
|
|
15
17
|
import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
|
|
16
18
|
import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
|
|
17
|
-
import {
|
|
19
|
+
import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
|
|
20
|
+
import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
|
|
21
|
+
import { buildClaudePermissionOptions } from "./permissions/options.js";
|
|
22
|
+
import { buildClaudePermissionPresentation } from "./permissions/presentation.js";
|
|
23
|
+
import { decodeClaudePermissionResponse } from "./permissions/response.js";
|
|
24
|
+
import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
|
|
25
|
+
import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
|
|
26
|
+
import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
18
27
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
28
|
+
import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
|
|
29
|
+
import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
|
30
|
+
import { parseToolResultMeta } from "./tool-result-meta.js";
|
|
31
|
+
export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
|
32
|
+
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
|
|
19
33
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
20
34
|
const execFileAsync = promisify(execFile);
|
|
21
|
-
const
|
|
22
|
-
function sanitizeTitle(text) {
|
|
23
|
-
// Replace newlines and collapse whitespace
|
|
24
|
-
const sanitized = text
|
|
25
|
-
.replace(/[\r\n]+/g, " ")
|
|
26
|
-
.replace(/\s+/g, " ")
|
|
27
|
-
.trim();
|
|
28
|
-
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
|
29
|
-
return sanitized;
|
|
30
|
-
}
|
|
31
|
-
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
|
32
|
-
}
|
|
35
|
+
const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
|
|
33
36
|
const ZERO_USAGE = Object.freeze({
|
|
34
37
|
input_tokens: 0,
|
|
35
38
|
output_tokens: 0,
|
|
@@ -114,11 +117,13 @@ function parseSteerRequest(params) {
|
|
|
114
117
|
* result is the turn's real terminal.
|
|
115
118
|
*
|
|
116
119
|
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
|
117
|
-
* lane
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* result
|
|
121
|
-
*
|
|
120
|
+
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
|
121
|
+
* attribute this" marker, which gets the same safe default. Misrouting a
|
|
122
|
+
* USER result into the autonomous lane hangs the prompt un-detectably
|
|
123
|
+
* (the result is skipped, its trailing idle absorbed as owed, so the
|
|
124
|
+
* #825 detector can't fire); misrouting an autonomous result into the
|
|
125
|
+
* user lane is the bounded misattribution class this set exists to
|
|
126
|
+
* reduce. */
|
|
122
127
|
const AUTONOMOUS_RESULT_ORIGINS = new Set([
|
|
123
128
|
"task-notification",
|
|
124
129
|
"peer",
|
|
@@ -157,17 +162,9 @@ function computeSessionFingerprint(params) {
|
|
|
157
162
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
158
163
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
159
164
|
}
|
|
160
|
-
/**
|
|
161
|
-
* The single provider ID this agent exposes via `providers/*`. Claude Code has
|
|
162
|
-
* one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
|
|
163
|
-
* is exactly one configurable provider.
|
|
164
|
-
*/
|
|
165
|
-
const PROVIDER_ID = "main";
|
|
166
|
-
/**
|
|
167
|
-
* Protocols the `main` provider can be configured with. These mirror the
|
|
168
|
-
* env-var mappings understood by {@link createEnvForProvider}.
|
|
169
|
-
*/
|
|
170
165
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
166
|
+
const PROVIDER_ID = "main";
|
|
167
|
+
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
171
168
|
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
|
172
169
|
function supportsSubagentTranscript(capabilities) {
|
|
173
170
|
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
@@ -296,9 +293,6 @@ function shouldHideClaudeAuth() {
|
|
|
296
293
|
* query stream has already ended (ran to `done` or died). The stream is not
|
|
297
294
|
* revivable, so the only recovery is a fresh session. */
|
|
298
295
|
const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
|
|
299
|
-
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
300
|
-
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
301
|
-
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
302
296
|
// Slash commands that the SDK handles locally without replaying the user
|
|
303
297
|
// message and without invoking the model.
|
|
304
298
|
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
@@ -408,189 +402,6 @@ export function isSyntheticLoginMessage(apiMessage) {
|
|
|
408
402
|
typeof block.text === "string" &&
|
|
409
403
|
block.text.includes("Please run /login"));
|
|
410
404
|
}
|
|
411
|
-
const PERMISSION_MODE_ALIASES = {
|
|
412
|
-
auto: "auto",
|
|
413
|
-
default: "default",
|
|
414
|
-
// Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
|
|
415
|
-
// `"defaultMode": "manual"` in settings.json; honor the same alias here.
|
|
416
|
-
manual: "default",
|
|
417
|
-
acceptedits: "acceptEdits",
|
|
418
|
-
dontask: "dontAsk",
|
|
419
|
-
plan: "plan",
|
|
420
|
-
bypasspermissions: "bypassPermissions",
|
|
421
|
-
bypass: "bypassPermissions",
|
|
422
|
-
};
|
|
423
|
-
export function resolvePermissionMode(defaultMode, logger = console) {
|
|
424
|
-
if (defaultMode === undefined) {
|
|
425
|
-
return "default";
|
|
426
|
-
}
|
|
427
|
-
if (typeof defaultMode !== "string") {
|
|
428
|
-
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
|
|
429
|
-
return "default";
|
|
430
|
-
}
|
|
431
|
-
const normalized = defaultMode.trim().toLowerCase();
|
|
432
|
-
if (normalized === "") {
|
|
433
|
-
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
|
|
434
|
-
return "default";
|
|
435
|
-
}
|
|
436
|
-
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
|
437
|
-
if (!mapped) {
|
|
438
|
-
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
|
|
439
|
-
return "default";
|
|
440
|
-
}
|
|
441
|
-
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
|
|
442
|
-
logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
|
|
443
|
-
return "default";
|
|
444
|
-
}
|
|
445
|
-
return mapped;
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Builds the label for the "Always Allow" permission option so the user can see
|
|
449
|
-
* the exact scope they are committing to. Uses the SDK-provided suggestions
|
|
450
|
-
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
|
|
451
|
-
* tool so "Always Allow" is never a blank check without disclosure.
|
|
452
|
-
*
|
|
453
|
-
* Fork-only layer. Upstream #930 moved the scope disclosure out of the option
|
|
454
|
-
* label and into `_meta.permission.changes`, leaving the label as a fixed
|
|
455
|
-
* "Always Allow". No shipping client reads that metadata yet — Zed in
|
|
456
|
-
* particular has no reference to it — so on the label alone the user would be
|
|
457
|
-
* granting a rule they cannot see. This restores the disclosure in the label
|
|
458
|
-
* WITHOUT dropping the upstream metadata: both travel on the same option, and
|
|
459
|
-
* the label can be retired once a client renders the structured form.
|
|
460
|
-
*/
|
|
461
|
-
export function describeAlwaysAllow(suggestions, toolName) {
|
|
462
|
-
if (!suggestions || suggestions.length === 0) {
|
|
463
|
-
return `Always Allow all ${toolName}`;
|
|
464
|
-
}
|
|
465
|
-
const ruleLabels = [];
|
|
466
|
-
const directories = [];
|
|
467
|
-
for (const update of suggestions) {
|
|
468
|
-
if (update.type === "addRules" && update.behavior === "allow") {
|
|
469
|
-
for (const rule of update.rules) {
|
|
470
|
-
ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else if (update.type === "addDirectories") {
|
|
474
|
-
directories.push(...update.directories);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
const parts = [];
|
|
478
|
-
if (ruleLabels.length > 0) {
|
|
479
|
-
parts.push(ruleLabels.join(", "));
|
|
480
|
-
}
|
|
481
|
-
if (directories.length > 0) {
|
|
482
|
-
parts.push(`access to ${directories.join(", ")}`);
|
|
483
|
-
}
|
|
484
|
-
if (parts.length === 0) {
|
|
485
|
-
return `Always Allow all ${toolName}`;
|
|
486
|
-
}
|
|
487
|
-
return `Always Allow ${parts.join(" and ")}`;
|
|
488
|
-
}
|
|
489
|
-
function permissionLifetime(destination) {
|
|
490
|
-
switch (destination) {
|
|
491
|
-
case "session":
|
|
492
|
-
return { scope: "session" };
|
|
493
|
-
case "cliArg":
|
|
494
|
-
return { scope: "process", storage: "cli_argument" };
|
|
495
|
-
case "userSettings":
|
|
496
|
-
return { scope: "persistent", storage: "user" };
|
|
497
|
-
case "projectSettings":
|
|
498
|
-
return { scope: "persistent", storage: "project" };
|
|
499
|
-
case "localSettings":
|
|
500
|
-
return { scope: "persistent", storage: "project_local" };
|
|
501
|
-
default:
|
|
502
|
-
return { scope: "unknown" };
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
function permissionMetadataForAlwaysAllow(suggestions, toolName) {
|
|
506
|
-
const effectiveSuggestions = suggestions && suggestions.length > 0
|
|
507
|
-
? suggestions
|
|
508
|
-
: [
|
|
509
|
-
{
|
|
510
|
-
type: "addRules",
|
|
511
|
-
rules: [{ toolName }],
|
|
512
|
-
behavior: "allow",
|
|
513
|
-
destination: "session",
|
|
514
|
-
},
|
|
515
|
-
];
|
|
516
|
-
const changes = [];
|
|
517
|
-
for (const update of effectiveSuggestions) {
|
|
518
|
-
switch (update.type) {
|
|
519
|
-
case "addRules":
|
|
520
|
-
case "removeRules":
|
|
521
|
-
case "replaceRules": {
|
|
522
|
-
const operation = update.type === "addRules" ? "add" : update.type === "removeRules" ? "remove" : "replace";
|
|
523
|
-
const targets = update.rules.map((rule) => ({
|
|
524
|
-
type: "tool",
|
|
525
|
-
toolName: rule.toolName,
|
|
526
|
-
...(rule.ruleContent
|
|
527
|
-
? {
|
|
528
|
-
matcher: {
|
|
529
|
-
type: "provider_rule",
|
|
530
|
-
provider: "claudeCode",
|
|
531
|
-
value: rule.ruleContent,
|
|
532
|
-
},
|
|
533
|
-
}
|
|
534
|
-
: {}),
|
|
535
|
-
}));
|
|
536
|
-
const renderedRules = update.rules
|
|
537
|
-
.map((rule) => rule.ruleContent
|
|
538
|
-
? `${rule.toolName} calls matching ${rule.ruleContent}`
|
|
539
|
-
: `all ${rule.toolName} calls`)
|
|
540
|
-
.join(", ");
|
|
541
|
-
const verb = operation === "add"
|
|
542
|
-
? update.behavior === "allow"
|
|
543
|
-
? "Allow"
|
|
544
|
-
: update.behavior === "deny"
|
|
545
|
-
? "Deny"
|
|
546
|
-
: "Ask before"
|
|
547
|
-
: operation === "remove"
|
|
548
|
-
? `Remove ${update.behavior} rules for`
|
|
549
|
-
: `Replace ${update.behavior} rules with`;
|
|
550
|
-
changes.push({
|
|
551
|
-
type: "policy_rule",
|
|
552
|
-
operation,
|
|
553
|
-
ruleBehavior: update.behavior,
|
|
554
|
-
description: `${verb} ${renderedRules}`,
|
|
555
|
-
lifetime: permissionLifetime(update.destination),
|
|
556
|
-
targets,
|
|
557
|
-
});
|
|
558
|
-
break;
|
|
559
|
-
}
|
|
560
|
-
case "addDirectories":
|
|
561
|
-
case "removeDirectories": {
|
|
562
|
-
const operation = update.type === "addDirectories" ? "add" : "remove";
|
|
563
|
-
changes.push({
|
|
564
|
-
type: "policy_rule",
|
|
565
|
-
operation,
|
|
566
|
-
ruleBehavior: "allow",
|
|
567
|
-
description: operation === "add"
|
|
568
|
-
? `Allow filesystem access under ${update.directories.join(", ")}`
|
|
569
|
-
: `Remove additional filesystem access under ${update.directories.join(", ")}`,
|
|
570
|
-
lifetime: permissionLifetime(update.destination),
|
|
571
|
-
targets: update.directories.map((path) => ({
|
|
572
|
-
type: "filesystem",
|
|
573
|
-
matcher: { type: "directory", path },
|
|
574
|
-
})),
|
|
575
|
-
});
|
|
576
|
-
break;
|
|
577
|
-
}
|
|
578
|
-
case "setMode":
|
|
579
|
-
changes.push({
|
|
580
|
-
type: "permission_mode",
|
|
581
|
-
operation: "set",
|
|
582
|
-
provider: "claudeCode",
|
|
583
|
-
mode: update.mode,
|
|
584
|
-
description: `Set Claude Code permission mode to ${update.mode}`,
|
|
585
|
-
lifetime: permissionLifetime(update.destination),
|
|
586
|
-
});
|
|
587
|
-
break;
|
|
588
|
-
default:
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
return { version: 1, changes };
|
|
593
|
-
}
|
|
594
405
|
/**
|
|
595
406
|
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
|
596
407
|
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
|
@@ -628,16 +439,38 @@ class ClientConnection {
|
|
|
628
439
|
return this.ctx.notify(method, params);
|
|
629
440
|
}
|
|
630
441
|
}
|
|
442
|
+
function raceWithAbort(operation, signal) {
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
445
|
+
const onAbort = () => {
|
|
446
|
+
cleanup();
|
|
447
|
+
reject(new Error("Tool use aborted"));
|
|
448
|
+
};
|
|
449
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
450
|
+
if (signal.aborted) {
|
|
451
|
+
onAbort();
|
|
452
|
+
}
|
|
453
|
+
void operation.then((value) => {
|
|
454
|
+
cleanup();
|
|
455
|
+
resolve(value);
|
|
456
|
+
}, (error) => {
|
|
457
|
+
cleanup();
|
|
458
|
+
reject(error);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
631
462
|
export class ClaudeAcpAgent {
|
|
632
463
|
sessions;
|
|
633
464
|
client;
|
|
634
465
|
clientCapabilities;
|
|
635
466
|
logger;
|
|
467
|
+
sessionModes;
|
|
636
468
|
gatewayAuthRequest;
|
|
637
|
-
/**
|
|
638
|
-
* never persisted to disk (see the Configurable LLM Providers RFD). When
|
|
639
|
-
* set, it takes precedence over {@link gatewayAuthRequest}. */
|
|
469
|
+
/** Set while ACP overrides the agent's native provider configuration. */
|
|
640
470
|
providerConfig;
|
|
471
|
+
/** Serializes provider changes while every open query is recreated between turns. */
|
|
472
|
+
providerUpdate = null;
|
|
473
|
+
exitPlan;
|
|
641
474
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
642
475
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
643
476
|
* tests can shrink it. */
|
|
@@ -646,6 +479,48 @@ export class ClaudeAcpAgent {
|
|
|
646
479
|
this.sessions = {};
|
|
647
480
|
this.client = client;
|
|
648
481
|
this.logger = logger ?? console;
|
|
482
|
+
this.exitPlan = new ExitPlanCoordinator({
|
|
483
|
+
currentSession: (id) => this.sessions[id],
|
|
484
|
+
closeQueryStream: (session) => this.closeQueryStream(session),
|
|
485
|
+
restartSession: async (params, options) => {
|
|
486
|
+
await this.createSession(params, options);
|
|
487
|
+
const session = this.sessions[options.publicSessionId];
|
|
488
|
+
if (!session)
|
|
489
|
+
throw new Error("Fresh Claude context was not created");
|
|
490
|
+
return session;
|
|
491
|
+
},
|
|
492
|
+
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
|
|
493
|
+
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
494
|
+
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
|
|
495
|
+
logError: (message, error) => this.logger.error(message, error),
|
|
496
|
+
destroyReplacement: (id, session) => {
|
|
497
|
+
disarmForceCancel(session);
|
|
498
|
+
session.cancelController?.abort();
|
|
499
|
+
this.closeQueryStream(session);
|
|
500
|
+
session.abortController.abort();
|
|
501
|
+
if (this.sessions[id] === session)
|
|
502
|
+
delete this.sessions[id];
|
|
503
|
+
},
|
|
504
|
+
settleCancelledTurn: (original, session, turn) => {
|
|
505
|
+
disarmForceCancel(session);
|
|
506
|
+
this.finishFileChangeAudit(session, turn, "cancelled");
|
|
507
|
+
turn.settled = true;
|
|
508
|
+
turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
|
|
509
|
+
},
|
|
510
|
+
settleFailedTurn: (session, turn, error) => {
|
|
511
|
+
disarmForceCancel(session);
|
|
512
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
513
|
+
turn.settled = true;
|
|
514
|
+
turn.reject(error);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
this.sessionModes = new SessionModeManager({
|
|
518
|
+
getSession: (sessionId) => this.sessions[sessionId],
|
|
519
|
+
sessionEndedMessage: SESSION_ENDED_MESSAGE,
|
|
520
|
+
updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
|
|
521
|
+
sessionUpdate: (params) => this.client.sessionUpdate(params),
|
|
522
|
+
logError: (...args) => this.logger.error(...args),
|
|
523
|
+
});
|
|
649
524
|
}
|
|
650
525
|
async initialize(request) {
|
|
651
526
|
this.clientCapabilities = request.clientCapabilities;
|
|
@@ -790,6 +665,7 @@ export class ClaudeAcpAgent {
|
|
|
790
665
|
// steering extension contract: advertises the `_session/steering` request
|
|
791
666
|
// so clients know they may inject a follow-up into a running turn.
|
|
792
667
|
_meta: {
|
|
668
|
+
...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
|
|
793
669
|
steering: {
|
|
794
670
|
supported: true,
|
|
795
671
|
},
|
|
@@ -802,6 +678,8 @@ export class ClaudeAcpAgent {
|
|
|
802
678
|
};
|
|
803
679
|
}
|
|
804
680
|
async newSession(params) {
|
|
681
|
+
if (this.providerUpdate)
|
|
682
|
+
await this.providerUpdate;
|
|
805
683
|
const response = await this.createSession(params, {
|
|
806
684
|
// Revisit these meta values once we support resume
|
|
807
685
|
resume: params._meta?.claudeCode?.options?.resume,
|
|
@@ -813,6 +691,8 @@ export class ClaudeAcpAgent {
|
|
|
813
691
|
return response;
|
|
814
692
|
}
|
|
815
693
|
async unstable_forkSession(params) {
|
|
694
|
+
if (this.providerUpdate)
|
|
695
|
+
await this.providerUpdate;
|
|
816
696
|
const response = await this.createSession({
|
|
817
697
|
cwd: params.cwd,
|
|
818
698
|
mcpServers: params.mcpServers ?? [],
|
|
@@ -829,6 +709,8 @@ export class ClaudeAcpAgent {
|
|
|
829
709
|
return response;
|
|
830
710
|
}
|
|
831
711
|
async resumeSession(params) {
|
|
712
|
+
if (this.providerUpdate)
|
|
713
|
+
await this.providerUpdate;
|
|
832
714
|
const result = await this.getOrCreateSession(params);
|
|
833
715
|
// Needs to happen after we return the session
|
|
834
716
|
setTimeout(() => {
|
|
@@ -837,6 +719,8 @@ export class ClaudeAcpAgent {
|
|
|
837
719
|
return result;
|
|
838
720
|
}
|
|
839
721
|
async loadSession(params) {
|
|
722
|
+
if (this.providerUpdate)
|
|
723
|
+
await this.providerUpdate;
|
|
840
724
|
const result = await this.getOrCreateSession(params);
|
|
841
725
|
await this.replaySessionHistory(params.sessionId);
|
|
842
726
|
// Send available commands after replay so it doesn't interleave with history
|
|
@@ -862,40 +746,6 @@ export class ClaudeAcpAgent {
|
|
|
862
746
|
sessions,
|
|
863
747
|
};
|
|
864
748
|
}
|
|
865
|
-
/** Read the SDK-maintained title for a session and, if it changed since the
|
|
866
|
-
* last time we looked, notify the client with a `session_info_update`. The
|
|
867
|
-
* SDK has no push event for the title it auto-generates in the background, so
|
|
868
|
-
* we pull it at turn-end. A missing session file or read error is non-fatal:
|
|
869
|
-
* the title is best-effort and another turn will retry. */
|
|
870
|
-
async maybeUpdateSessionTitle(sessionId, session) {
|
|
871
|
-
let info;
|
|
872
|
-
try {
|
|
873
|
-
info = await getSessionInfo(sessionId, { dir: session.cwd });
|
|
874
|
-
}
|
|
875
|
-
catch (error) {
|
|
876
|
-
this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
// `customTitle` is a user-set `/rename`; `summary` is the auto-generated
|
|
880
|
-
// title (or first prompt). Prefer the explicit title when present.
|
|
881
|
-
const rawTitle = info?.customTitle ?? info?.summary;
|
|
882
|
-
if (!rawTitle) {
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
const title = sanitizeTitle(rawTitle);
|
|
886
|
-
if (title === session.lastTitle) {
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
session.lastTitle = title;
|
|
890
|
-
await this.client.sessionUpdate({
|
|
891
|
-
sessionId,
|
|
892
|
-
update: {
|
|
893
|
-
sessionUpdate: "session_info_update",
|
|
894
|
-
title,
|
|
895
|
-
updatedAt: new Date(info.lastModified).toISOString(),
|
|
896
|
-
},
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
749
|
async authenticate(_params) {
|
|
900
750
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
901
751
|
this.gatewayAuthRequest = _params;
|
|
@@ -903,21 +753,14 @@ export class ClaudeAcpAgent {
|
|
|
903
753
|
}
|
|
904
754
|
throw new Error("Method not implemented.");
|
|
905
755
|
}
|
|
906
|
-
/**
|
|
907
|
-
* `providers/list` — returns the single client-configurable custom gateway
|
|
908
|
-
* provider (`main`). `current` carries only non-secret routing (never headers,
|
|
909
|
-
* which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
|
|
910
|
-
* display, and is `null` when the provider is not configured/disabled. The
|
|
911
|
-
* provider is optional (`required: false`): while disabled/unconfigured the
|
|
912
|
-
* agent falls back to its own default routing (normal Claude login).
|
|
913
|
-
*/
|
|
914
756
|
async unstable_listProviders(_params) {
|
|
915
|
-
const config = this.
|
|
757
|
+
const config = this.providerConfig ?? this.defaultProviderConfig();
|
|
758
|
+
this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
|
|
916
759
|
const provider = {
|
|
917
760
|
providerId: PROVIDER_ID,
|
|
918
761
|
supported: SUPPORTED_PROTOCOLS,
|
|
919
762
|
required: false,
|
|
920
|
-
current:
|
|
763
|
+
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
|
921
764
|
};
|
|
922
765
|
return { providers: [provider] };
|
|
923
766
|
}
|
|
@@ -955,34 +798,49 @@ export class ClaudeAcpAgent {
|
|
|
955
798
|
}
|
|
956
799
|
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
|
957
800
|
}
|
|
958
|
-
this.
|
|
801
|
+
this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
|
|
802
|
+
await this.enqueueProviderUpdate(config);
|
|
959
803
|
return {};
|
|
960
804
|
}
|
|
961
805
|
/**
|
|
962
|
-
* `providers/disable`
|
|
963
|
-
*
|
|
964
|
-
* so the agent reverts to its own default routing and `providers/list` reports
|
|
965
|
-
* `current: null`. Disabling any other (unknown) ID is treated as a successful
|
|
966
|
-
* no-op per the RFD's idempotency rule.
|
|
806
|
+
* `providers/disable` ends ACP ownership of the single mutually exclusive
|
|
807
|
+
* backend slot and restores the agent's native routing state.
|
|
967
808
|
*/
|
|
968
809
|
async unstable_disableProvider(params) {
|
|
969
810
|
if (params.providerId === PROVIDER_ID) {
|
|
970
|
-
this.
|
|
971
|
-
this.
|
|
811
|
+
this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
|
|
812
|
+
await this.enqueueProviderUpdate(undefined);
|
|
972
813
|
}
|
|
973
814
|
// Unknown provider: idempotent success.
|
|
974
815
|
return {};
|
|
975
816
|
}
|
|
976
|
-
/**
|
|
977
|
-
* Resolve the effective client-managed routing config. `providers/set` takes
|
|
978
|
-
* precedence; otherwise fall back to the legacy gateway auth request. Returns
|
|
979
|
-
* `null` when neither is configured.
|
|
980
|
-
*/
|
|
981
817
|
resolveProviderConfig() {
|
|
982
|
-
|
|
983
|
-
|
|
818
|
+
return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
819
|
+
}
|
|
820
|
+
defaultProviderConfig() {
|
|
821
|
+
const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
822
|
+
if (gatewayConfig) {
|
|
823
|
+
return gatewayConfig;
|
|
824
|
+
}
|
|
825
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
|
826
|
+
return {
|
|
827
|
+
apiType: "bedrock",
|
|
828
|
+
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
|
829
|
+
headers: {},
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
|
833
|
+
return {
|
|
834
|
+
apiType: "vertex",
|
|
835
|
+
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
|
|
836
|
+
headers: {},
|
|
837
|
+
};
|
|
984
838
|
}
|
|
985
|
-
return
|
|
839
|
+
return {
|
|
840
|
+
apiType: "anthropic",
|
|
841
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
|
842
|
+
headers: {},
|
|
843
|
+
};
|
|
986
844
|
}
|
|
987
845
|
async logout(_params) {
|
|
988
846
|
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
|
@@ -1012,6 +870,8 @@ export class ClaudeAcpAgent {
|
|
|
1012
870
|
}
|
|
1013
871
|
}
|
|
1014
872
|
async prompt(params) {
|
|
873
|
+
if (this.providerUpdate)
|
|
874
|
+
await this.providerUpdate;
|
|
1015
875
|
const session = this.sessions[params.sessionId];
|
|
1016
876
|
if (!session) {
|
|
1017
877
|
throw new Error("Session not found");
|
|
@@ -1093,6 +953,12 @@ export class ClaudeAcpAgent {
|
|
|
1093
953
|
}
|
|
1094
954
|
return { stopReason: "end_turn" };
|
|
1095
955
|
}
|
|
956
|
+
if (session.autoModeFallbackWarningPending) {
|
|
957
|
+
await this.sessionModes.publishFallbackWarning(params.sessionId, session);
|
|
958
|
+
}
|
|
959
|
+
if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
|
|
960
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
961
|
+
}
|
|
1096
962
|
// Lazy Thinking application (R1.3): a pending config change is applied by
|
|
1097
963
|
// recreating the SDK query with session resume BEFORE this turn is
|
|
1098
964
|
// enqueued — never mid-turn, so only when no other prompt is in flight. A
|
|
@@ -1126,6 +992,16 @@ export class ClaudeAcpAgent {
|
|
|
1126
992
|
// user message, so the consumer can't promote the turn from the echo.
|
|
1127
993
|
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
1128
994
|
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
995
|
+
const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
996
|
+
? agentFileChangeReportRequestId(params._meta)
|
|
997
|
+
: undefined;
|
|
998
|
+
let fileChangeAudit;
|
|
999
|
+
if (fileChangeReportRequestId &&
|
|
1000
|
+
!session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
|
|
1001
|
+
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
|
|
1002
|
+
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
|
|
1003
|
+
}
|
|
1004
|
+
session.titles.onPrompt(params.prompt);
|
|
1129
1005
|
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
1130
1006
|
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
1131
1007
|
// turn, pushes the user message onto the streaming input, makes sure the
|
|
@@ -1133,13 +1009,24 @@ export class ClaudeAcpAgent {
|
|
|
1133
1009
|
const turn = {
|
|
1134
1010
|
promptUuid,
|
|
1135
1011
|
isLocalOnlyCommand,
|
|
1012
|
+
...(fileChangeAudit ? { fileChangeAudit } : {}),
|
|
1136
1013
|
settled: false,
|
|
1137
1014
|
resolve: () => { },
|
|
1138
1015
|
reject: () => { },
|
|
1139
1016
|
};
|
|
1017
|
+
let completeTurn;
|
|
1018
|
+
turn.completion = new Promise((resolve) => {
|
|
1019
|
+
completeTurn = resolve;
|
|
1020
|
+
});
|
|
1140
1021
|
const response = new Promise((resolve, reject) => {
|
|
1141
|
-
turn.resolve =
|
|
1142
|
-
|
|
1022
|
+
turn.resolve = (result) => {
|
|
1023
|
+
resolve(result);
|
|
1024
|
+
completeTurn();
|
|
1025
|
+
};
|
|
1026
|
+
turn.reject = (error) => {
|
|
1027
|
+
reject(error);
|
|
1028
|
+
completeTurn();
|
|
1029
|
+
};
|
|
1143
1030
|
});
|
|
1144
1031
|
session.turnQueue ??= [];
|
|
1145
1032
|
session.turnQueue.push(turn);
|
|
@@ -1174,6 +1061,15 @@ export class ClaudeAcpAgent {
|
|
|
1174
1061
|
},
|
|
1175
1062
|
});
|
|
1176
1063
|
}
|
|
1064
|
+
async publishTaskPlan(sessionId, taskState) {
|
|
1065
|
+
await this.client.sessionUpdate({
|
|
1066
|
+
sessionId,
|
|
1067
|
+
update: {
|
|
1068
|
+
sessionUpdate: "plan",
|
|
1069
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1177
1073
|
async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
|
|
1178
1074
|
const goalUpdate = goalUpdateFromPrompt(prompt);
|
|
1179
1075
|
if (goalUpdate !== undefined) {
|
|
@@ -1287,6 +1183,15 @@ export class ClaudeAcpAgent {
|
|
|
1287
1183
|
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
|
1288
1184
|
return { outcome: "injected" };
|
|
1289
1185
|
}
|
|
1186
|
+
/** Publish the audit terminal for every turn path that did not reach the
|
|
1187
|
+
* report tool. The support flips the turn state synchronously before its
|
|
1188
|
+
* transport await, so callers can stay fail-open and settle the ACP prompt
|
|
1189
|
+
* immediately without allowing a racing lifecycle path to publish twice. */
|
|
1190
|
+
finishFileChangeAudit(session, turn, reason) {
|
|
1191
|
+
if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
|
|
1192
|
+
return;
|
|
1193
|
+
void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
|
|
1194
|
+
}
|
|
1290
1195
|
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
1291
1196
|
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
1292
1197
|
ensureConsumer(session, sessionId) {
|
|
@@ -1321,6 +1226,8 @@ export class ClaudeAcpAgent {
|
|
|
1321
1226
|
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
1322
1227
|
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
1323
1228
|
let lastAssistantError;
|
|
1229
|
+
let lastAssistantWasUsageLimit = false;
|
|
1230
|
+
let lastAssistantFailureTitle;
|
|
1324
1231
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
1325
1232
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
1326
1233
|
// human-readable explanation so the terminal `result` can surface it.
|
|
@@ -1366,19 +1273,67 @@ export class ClaudeAcpAgent {
|
|
|
1366
1273
|
* as the turn's answer. */
|
|
1367
1274
|
const sendUpdate = async (notification) => {
|
|
1368
1275
|
const { update } = notification;
|
|
1276
|
+
if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
|
|
1277
|
+
(update.sessionUpdate === "agent_message_chunk" ||
|
|
1278
|
+
update.sessionUpdate === "agent_thought_chunk" ||
|
|
1279
|
+
update.sessionUpdate === "user_message_chunk" ||
|
|
1280
|
+
update.sessionUpdate === "tool_call" ||
|
|
1281
|
+
update.sessionUpdate === "tool_call_update")) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1369
1284
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1370
1285
|
const claudeMeta = update._meta?.claudeCode;
|
|
1371
1286
|
if (!claudeMeta?.parentToolUseId) {
|
|
1372
1287
|
session.emittedAssistantText = true;
|
|
1288
|
+
session.titles.onAssistantText(update.content);
|
|
1373
1289
|
}
|
|
1374
1290
|
}
|
|
1375
1291
|
await this.client.sessionUpdate(notification);
|
|
1376
1292
|
};
|
|
1293
|
+
let pendingWorkerShutdown = false;
|
|
1294
|
+
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
1295
|
+
const sessionFailures = new SessionFailureController({
|
|
1296
|
+
sessionId: params.sessionId,
|
|
1297
|
+
state: session.sessionFailureState,
|
|
1298
|
+
capabilities: this.clientCapabilities,
|
|
1299
|
+
isCurrent: isCurrentConsumer,
|
|
1300
|
+
sendUpdate,
|
|
1301
|
+
logger: this.logger,
|
|
1302
|
+
});
|
|
1303
|
+
const createSessionFailure = async (kind, options = {}) => {
|
|
1304
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1305
|
+
return sessionFailures.prepare(kind, {
|
|
1306
|
+
turnId,
|
|
1307
|
+
sessionScoped: options.turnScoped === false,
|
|
1308
|
+
title: options.title,
|
|
1309
|
+
details: options.details,
|
|
1310
|
+
severity: options.severity,
|
|
1311
|
+
});
|
|
1312
|
+
};
|
|
1313
|
+
const publishSessionFailure = async (kind, options = {}) => {
|
|
1314
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1315
|
+
await sessionFailures.publish(kind, {
|
|
1316
|
+
turnId,
|
|
1317
|
+
sessionScoped: options.turnScoped === false,
|
|
1318
|
+
title: options.title,
|
|
1319
|
+
details: options.details,
|
|
1320
|
+
severity: options.severity,
|
|
1321
|
+
});
|
|
1322
|
+
};
|
|
1323
|
+
const clearFailuresFromEarlierTurns = async () => {
|
|
1324
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
1325
|
+
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
|
|
1326
|
+
// They are session-scoped and stay until superseded or dismissed by the user.
|
|
1327
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
|
|
1328
|
+
};
|
|
1329
|
+
const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
|
|
1377
1330
|
const resetTurnScratch = () => {
|
|
1378
1331
|
lastAssistantTotalUsage = null;
|
|
1379
1332
|
lastAssistantUsage = null;
|
|
1380
1333
|
lastAssistantModel = null;
|
|
1381
1334
|
lastAssistantError = undefined;
|
|
1335
|
+
lastAssistantWasUsageLimit = false;
|
|
1336
|
+
lastAssistantFailureTitle = undefined;
|
|
1382
1337
|
lastRefusalExplanation = null;
|
|
1383
1338
|
compactionInProgress = false;
|
|
1384
1339
|
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
@@ -1390,12 +1345,14 @@ export class ClaudeAcpAgent {
|
|
|
1390
1345
|
// cleared when each consolidated message consumes it. #785 stopped
|
|
1391
1346
|
// resetting the streamed-content tracking here but left this line.
|
|
1392
1347
|
stopReason = "end_turn";
|
|
1393
|
-
session.accumulatedUsage = {
|
|
1348
|
+
session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
|
|
1394
1349
|
inputTokens: 0,
|
|
1395
1350
|
outputTokens: 0,
|
|
1396
1351
|
cachedReadTokens: 0,
|
|
1397
1352
|
cachedWriteTokens: 0,
|
|
1398
1353
|
};
|
|
1354
|
+
if (session.activeTurn)
|
|
1355
|
+
session.activeTurn.carriedUsage = undefined;
|
|
1399
1356
|
};
|
|
1400
1357
|
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
1401
1358
|
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
@@ -1622,11 +1579,14 @@ export class ClaudeAcpAgent {
|
|
|
1622
1579
|
};
|
|
1623
1580
|
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
1624
1581
|
* backstop (the turn is over), and drop it from the queue. */
|
|
1625
|
-
const settleActive = (result
|
|
1582
|
+
const settleActive = (result, auditReason = result.stopReason === "cancelled"
|
|
1583
|
+
? "cancelled"
|
|
1584
|
+
: "notReported") => {
|
|
1626
1585
|
const turn = session.activeTurn;
|
|
1627
1586
|
if (!turn || turn.settled) {
|
|
1628
1587
|
return;
|
|
1629
1588
|
}
|
|
1589
|
+
this.finishFileChangeAudit(session, turn, auditReason);
|
|
1630
1590
|
// Captured before the settled flip below (isHeldOpen tests !settled).
|
|
1631
1591
|
const wasHeld = isHeldOpen(turn);
|
|
1632
1592
|
turn.settled = true;
|
|
@@ -1657,8 +1617,10 @@ export class ClaudeAcpAgent {
|
|
|
1657
1617
|
disarmForceCancel(session);
|
|
1658
1618
|
const turn = session.activeTurn;
|
|
1659
1619
|
if (!turn || turn.settled) {
|
|
1620
|
+
this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
|
|
1660
1621
|
return;
|
|
1661
1622
|
}
|
|
1623
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1662
1624
|
turn.settled = true;
|
|
1663
1625
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
1664
1626
|
session.activeTurn = null;
|
|
@@ -1670,6 +1632,31 @@ export class ClaudeAcpAgent {
|
|
|
1670
1632
|
session.emittedAssistantText = false;
|
|
1671
1633
|
turn.reject(error);
|
|
1672
1634
|
};
|
|
1635
|
+
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1636
|
+
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1637
|
+
* JSON-RPC rejection path. */
|
|
1638
|
+
const failActiveWithSessionFailure = async (kind, error, title) => {
|
|
1639
|
+
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1640
|
+
failActive(error);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (!session.activeTurn || session.activeTurn.settled) {
|
|
1644
|
+
this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
|
|
1645
|
+
await publishSessionFailure(kind, { turnScoped: false, title });
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const failure = await createSessionFailure(kind, { title });
|
|
1649
|
+
if (!failure) {
|
|
1650
|
+
failActive(error);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
sessionFailures.recordActive(failure);
|
|
1654
|
+
settleActive({
|
|
1655
|
+
stopReason: "end_turn",
|
|
1656
|
+
usage: sessionUsage(session),
|
|
1657
|
+
_meta: sessionFailureMeta(failure),
|
|
1658
|
+
}, "providerError");
|
|
1659
|
+
};
|
|
1673
1660
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1674
1661
|
const failAllTurns = (error) => {
|
|
1675
1662
|
disarmForceCancel(session);
|
|
@@ -1680,6 +1667,7 @@ export class ClaudeAcpAgent {
|
|
|
1680
1667
|
session.turnQueue = [];
|
|
1681
1668
|
for (const turn of turns) {
|
|
1682
1669
|
if (!turn.settled) {
|
|
1670
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1683
1671
|
const wasHeld = isHeldOpen(turn);
|
|
1684
1672
|
turn.settled = true;
|
|
1685
1673
|
if (wasHeld) {
|
|
@@ -1752,7 +1740,9 @@ export class ClaudeAcpAgent {
|
|
|
1752
1740
|
// spent would never drain (it would swallow an unrelated later
|
|
1753
1741
|
// echo-less result instead).
|
|
1754
1742
|
const active = session.activeTurn;
|
|
1755
|
-
if (active.commandFinished === "completed" ||
|
|
1743
|
+
if (active.commandFinished === "completed" ||
|
|
1744
|
+
active.commandFinished === "discarded" ||
|
|
1745
|
+
active.commandFinished === "refused") {
|
|
1756
1746
|
// Finished SDK-side; any result already passed. Nothing to
|
|
1757
1747
|
// track.
|
|
1758
1748
|
}
|
|
@@ -1812,6 +1802,24 @@ export class ClaudeAcpAgent {
|
|
|
1812
1802
|
if (session.query !== myQuery) {
|
|
1813
1803
|
return;
|
|
1814
1804
|
}
|
|
1805
|
+
if (pendingWorkerShutdown) {
|
|
1806
|
+
pendingWorkerShutdown = false;
|
|
1807
|
+
if (session.activeTurn) {
|
|
1808
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
1809
|
+
await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
// The held turn already has its authoritative terminal outcome,
|
|
1813
|
+
// but EOF permanently closes the non-revivable Query. Preserve
|
|
1814
|
+
// the turn result below and report the independent session-health
|
|
1815
|
+
// failure without a turnId so AIR can offer a new session.
|
|
1816
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
else {
|
|
1820
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1815
1823
|
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
1816
1824
|
// stream resources — same order as the error paths (failAllTurns before
|
|
1817
1825
|
// closeQueryStream). Settling is the user-facing contract; resource
|
|
@@ -1835,6 +1843,7 @@ export class ClaudeAcpAgent {
|
|
|
1835
1843
|
// still here was enqueued afterward and was not part of the cancel.)
|
|
1836
1844
|
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
1837
1845
|
if (!queued.settled) {
|
|
1846
|
+
this.finishFileChangeAudit(session, queued, "providerError");
|
|
1838
1847
|
queued.settled = true;
|
|
1839
1848
|
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
1840
1849
|
}
|
|
@@ -1856,7 +1865,7 @@ export class ClaudeAcpAgent {
|
|
|
1856
1865
|
}
|
|
1857
1866
|
// CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
|
|
1858
1867
|
// uuid-stamped queued command (queued/started/completed/cancelled/
|
|
1859
|
-
// discarded) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1868
|
+
// discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1860
1869
|
// prompt() stamps a uuid on every message. The frame is @internal and
|
|
1861
1870
|
// absent from the SDKMessage union, so handle it BEFORE the exhaustive
|
|
1862
1871
|
// switch: it must not reach `unreachable`'s error log, and a `case`
|
|
@@ -1891,6 +1900,7 @@ export class ClaudeAcpAgent {
|
|
|
1891
1900
|
}
|
|
1892
1901
|
case "completed":
|
|
1893
1902
|
case "discarded":
|
|
1903
|
+
case "refused":
|
|
1894
1904
|
case "cancelled": {
|
|
1895
1905
|
// Terminal frames. Latch the fate on a still-queued turn so a
|
|
1896
1906
|
// later cancel() doesn't seed an orphan entry for a command
|
|
@@ -1924,6 +1934,9 @@ export class ClaudeAcpAgent {
|
|
|
1924
1934
|
// command folded into another turn whose result is attributed
|
|
1925
1935
|
// elsewhere — either way no echo-less result remains to skip.
|
|
1926
1936
|
// "discarded" = session ended with it still queued; no result.
|
|
1937
|
+
// "refused" (2.1.238+) = a cross-session peer message declined
|
|
1938
|
+
// by receive-side policy before dispatch; never a prompt-lane
|
|
1939
|
+
// command of ours, and no result will ever come.
|
|
1927
1940
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
1928
1941
|
break;
|
|
1929
1942
|
}
|
|
@@ -1963,6 +1976,23 @@ export class ClaudeAcpAgent {
|
|
|
1963
1976
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
1964
1977
|
// session creation.
|
|
1965
1978
|
await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
1979
|
+
// Terminal-bound slash commands (absent when none, and on
|
|
1980
|
+
// older CLIs). The session/new advertisement runs before any
|
|
1981
|
+
// init frame can be observed, so the first latch (or a
|
|
1982
|
+
// genuine change) re-publishes the now-filtered list.
|
|
1983
|
+
if (message.terminal_slash_commands &&
|
|
1984
|
+
JSON.stringify(message.terminal_slash_commands) !==
|
|
1985
|
+
JSON.stringify(session.terminalSlashCommands)) {
|
|
1986
|
+
session.terminalSlashCommands = message.terminal_slash_commands;
|
|
1987
|
+
try {
|
|
1988
|
+
await this.sendAvailableCommandsUpdate(message.session_id);
|
|
1989
|
+
}
|
|
1990
|
+
catch (error) {
|
|
1991
|
+
// Advisory reconcile only — the client keeps its current
|
|
1992
|
+
// (unfiltered) list; never fail the turn over it.
|
|
1993
|
+
this.logger.error(`Failed to re-advertise slash commands: ${error}`);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1966
1996
|
break;
|
|
1967
1997
|
case "status": {
|
|
1968
1998
|
// These banners count as delivered text (via sendUpdate), so
|
|
@@ -2030,6 +2060,7 @@ export class ClaudeAcpAgent {
|
|
|
2030
2060
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
2031
2061
|
lastAssistantUsage = null;
|
|
2032
2062
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
2063
|
+
session.contextUsedTokens = usedTokens ?? 0;
|
|
2033
2064
|
await sendUpdate({
|
|
2034
2065
|
sessionId: message.session_id,
|
|
2035
2066
|
update: {
|
|
@@ -2155,13 +2186,11 @@ export class ClaudeAcpAgent {
|
|
|
2155
2186
|
// the next prompt; only a timer could tell those apart.
|
|
2156
2187
|
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
2157
2188
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
2158
|
-
|
|
2189
|
+
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
2159
2190
|
}
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
|
|
2163
|
-
// landed. Push it to the client if it changed.
|
|
2164
|
-
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
2191
|
+
// Turn-over is when a title may have landed or become
|
|
2192
|
+
// generatable; see SessionTitles.onTurnEnd.
|
|
2193
|
+
await session.titles.onTurnEnd(session);
|
|
2165
2194
|
}
|
|
2166
2195
|
break;
|
|
2167
2196
|
}
|
|
@@ -2213,7 +2242,7 @@ export class ClaudeAcpAgent {
|
|
|
2213
2242
|
sessionId: message.session_id,
|
|
2214
2243
|
update: {
|
|
2215
2244
|
sessionUpdate: "available_commands_update",
|
|
2216
|
-
availableCommands: getAvailableSlashCommands(message.commands),
|
|
2245
|
+
availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
|
|
2217
2246
|
},
|
|
2218
2247
|
});
|
|
2219
2248
|
break;
|
|
@@ -2355,9 +2384,14 @@ export class ClaudeAcpAgent {
|
|
|
2355
2384
|
}
|
|
2356
2385
|
break;
|
|
2357
2386
|
case "worker_shutting_down":
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2387
|
+
// Defer until stream end. The announcement is durable and may be
|
|
2388
|
+
// replayed before later frames, but those frames do not prove that
|
|
2389
|
+
// a new worker epoch began: they can be buffered output from the
|
|
2390
|
+
// shutting-down worker. Keep the signal armed until the transport
|
|
2391
|
+
// actually ends. Deliberately do not add a quiet-period timer: the
|
|
2392
|
+
// iterator has no replay/live boundary, so a timeout could publish
|
|
2393
|
+
// while a slow replay is still in flight.
|
|
2394
|
+
pendingWorkerShutdown = true;
|
|
2361
2395
|
break;
|
|
2362
2396
|
case "elicitation_complete": {
|
|
2363
2397
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
@@ -2377,10 +2411,21 @@ export class ClaudeAcpAgent {
|
|
|
2377
2411
|
}
|
|
2378
2412
|
case "plugin_install":
|
|
2379
2413
|
case "notification":
|
|
2380
|
-
case "api_retry":
|
|
2381
2414
|
case "thinking_tokens":
|
|
2382
2415
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
2383
2416
|
break;
|
|
2417
|
+
case "api_retry": {
|
|
2418
|
+
const title = message.error_status === null
|
|
2419
|
+
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
|
|
2420
|
+
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
|
|
2421
|
+
await publishSessionFailure(message.error_status === null
|
|
2422
|
+
? "transport_lost"
|
|
2423
|
+
: providerFailureCategory(message.error), {
|
|
2424
|
+
title,
|
|
2425
|
+
severity: "warning",
|
|
2426
|
+
});
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2384
2429
|
case "model_refusal_fallback": {
|
|
2385
2430
|
// The SDK retried a refused turn on the fallback model and made
|
|
2386
2431
|
// the swap persistent for the session. Without a notice the
|
|
@@ -2394,26 +2439,53 @@ export class ClaudeAcpAgent {
|
|
|
2394
2439
|
// CLIs, where "revert" marked a turn-only fallback — for that
|
|
2395
2440
|
// direction the session stays on the original model, so skip
|
|
2396
2441
|
// the persistent-swap claim and the state sync.
|
|
2397
|
-
|
|
2442
|
+
//
|
|
2443
|
+
// `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
|
|
2444
|
+
// "local" means a subagent / side-question / background fork
|
|
2445
|
+
// response fell back and the session model is unchanged, so
|
|
2446
|
+
// syncing the picker would advertise a model the session
|
|
2447
|
+
// isn't running. Absent scope means an older CLI, where every
|
|
2448
|
+
// retry was a session-level swap — treat as "session".
|
|
2449
|
+
const local = message.scope === "local";
|
|
2450
|
+
const persistent = message.direction !== "revert" && !local;
|
|
2398
2451
|
const category = message.api_refusal_category
|
|
2399
2452
|
? ` (${message.api_refusal_category})`
|
|
2400
2453
|
: "";
|
|
2401
|
-
const explanation = message.api_refusal_explanation
|
|
2402
|
-
? `\n\n${message.api_refusal_explanation}`
|
|
2403
|
-
: "";
|
|
2404
2454
|
const outcome = persistent
|
|
2405
2455
|
? `The session will continue on ${message.fallback_model}.`
|
|
2406
|
-
:
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2456
|
+
: local
|
|
2457
|
+
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
|
2458
|
+
: `The session stays on ${message.original_model}.`;
|
|
2459
|
+
const fallbackSummary = `${message.original_model} declined this request${category}; ` +
|
|
2460
|
+
`retried with ${message.fallback_model}. ${outcome}`;
|
|
2461
|
+
const explanation = message.api_refusal_explanation || undefined;
|
|
2462
|
+
const fallbackNotice = explanation
|
|
2463
|
+
? `${fallbackSummary}\n\n${explanation}`
|
|
2464
|
+
: fallbackSummary;
|
|
2465
|
+
// A silent model swap is a session-level advisory, not something the model said.
|
|
2466
|
+
// Clients that negotiated typed records get it as one; the rest keep the bold-label
|
|
2467
|
+
// transcript line, which was the only way to flag it before.
|
|
2468
|
+
if (supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2469
|
+
const useDetails = explanation !== undefined &&
|
|
2470
|
+
fallbackSummary.length + 2 + explanation.length >
|
|
2471
|
+
MAX_INLINE_FAILURE_TITLE_LENGTH;
|
|
2472
|
+
await publishSessionFailure("advisory", {
|
|
2473
|
+
title: useDetails ? fallbackSummary : fallbackNotice,
|
|
2474
|
+
...(useDetails ? { details: explanation } : {}),
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
else {
|
|
2478
|
+
await sendUpdate({
|
|
2479
|
+
sessionId: message.session_id,
|
|
2480
|
+
update: {
|
|
2481
|
+
sessionUpdate: "agent_message_chunk",
|
|
2482
|
+
content: {
|
|
2483
|
+
type: "text",
|
|
2484
|
+
text: `**Model fallback:** ${fallbackNotice}`,
|
|
2485
|
+
},
|
|
2414
2486
|
},
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2417
2489
|
if (persistent) {
|
|
2418
2490
|
await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
|
|
2419
2491
|
}
|
|
@@ -2498,6 +2570,8 @@ export class ClaudeAcpAgent {
|
|
|
2498
2570
|
// user-turn lifecycle (stop reason, settles, failActive,
|
|
2499
2571
|
// slash-command output forwarding), though their cost is real.
|
|
2500
2572
|
const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
|
|
2573
|
+
const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
|
|
2574
|
+
const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
|
|
2501
2575
|
try {
|
|
2502
2576
|
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
2503
2577
|
// Gated to user-driven turns like every other side effect below;
|
|
@@ -2650,7 +2724,10 @@ export class ClaudeAcpAgent {
|
|
|
2650
2724
|
});
|
|
2651
2725
|
}
|
|
2652
2726
|
if (session.cancelled) {
|
|
2727
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2728
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2653
2729
|
if (!isAutonomousResult) {
|
|
2730
|
+
await clearFailuresFromEarlierTurns();
|
|
2654
2731
|
stopReason = "cancelled";
|
|
2655
2732
|
}
|
|
2656
2733
|
break;
|
|
@@ -2686,6 +2763,37 @@ export class ClaudeAcpAgent {
|
|
|
2686
2763
|
}
|
|
2687
2764
|
break;
|
|
2688
2765
|
}
|
|
2766
|
+
await clearFailuresFromEarlierTurns();
|
|
2767
|
+
// `interrupt: true` is required to make "No, keep planning"
|
|
2768
|
+
// terminate the ACP turn. Claude represents that intentional
|
|
2769
|
+
// interrupt as an error-shaped diagnostic, so translate only a
|
|
2770
|
+
// diagnostic causally paired with the recorded ExitPlanMode
|
|
2771
|
+
// permission response.
|
|
2772
|
+
const diagnostic = executionDiagnostic(message);
|
|
2773
|
+
if (pendingExitPlanModeInterruption &&
|
|
2774
|
+
pendingExitPlanModeInterruption.toolResultSeen &&
|
|
2775
|
+
message.is_error &&
|
|
2776
|
+
diagnostic &&
|
|
2777
|
+
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
|
2778
|
+
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
|
|
2779
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2780
|
+
if (pendingExitPlanContextReset &&
|
|
2781
|
+
pendingExitPlanContextReset.toolUseId ===
|
|
2782
|
+
pendingExitPlanModeInterruption.toolUseId) {
|
|
2783
|
+
await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
stopReason = "cancelled";
|
|
2787
|
+
settleOrDefer({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
2788
|
+
break;
|
|
2789
|
+
}
|
|
2790
|
+
if (pendingExitPlanModeInterruption) {
|
|
2791
|
+
// This result ended the interrupted cycle without the exact
|
|
2792
|
+
// correlated cancellation shape. Never carry its marker into
|
|
2793
|
+
// a later turn, whether or not the tool result was observed.
|
|
2794
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2795
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2796
|
+
}
|
|
2689
2797
|
// A refusal can arrive on any result subtype (and may even set
|
|
2690
2798
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2691
2799
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2722,10 +2830,15 @@ export class ClaudeAcpAgent {
|
|
|
2722
2830
|
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
2723
2831
|
break;
|
|
2724
2832
|
}
|
|
2833
|
+
if (!message.is_error && lastAssistantModel !== null) {
|
|
2834
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
2835
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
|
|
2836
|
+
(failure.severity === "warning" && failure.turnId === activeTurnId));
|
|
2837
|
+
}
|
|
2725
2838
|
switch (message.subtype) {
|
|
2726
2839
|
case "success": {
|
|
2727
2840
|
if (message.result.includes("Please run /login")) {
|
|
2728
|
-
|
|
2841
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
|
|
2729
2842
|
break;
|
|
2730
2843
|
}
|
|
2731
2844
|
if (message.stop_reason === "max_tokens") {
|
|
@@ -2733,7 +2846,7 @@ export class ClaudeAcpAgent {
|
|
|
2733
2846
|
break;
|
|
2734
2847
|
}
|
|
2735
2848
|
if (message.is_error) {
|
|
2736
|
-
|
|
2849
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
|
|
2737
2850
|
break;
|
|
2738
2851
|
}
|
|
2739
2852
|
// The result text is forwarded in two cases. Local-only
|
|
@@ -2769,17 +2882,29 @@ export class ClaudeAcpAgent {
|
|
|
2769
2882
|
break;
|
|
2770
2883
|
}
|
|
2771
2884
|
if (message.is_error) {
|
|
2772
|
-
|
|
2885
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
|
|
2773
2886
|
break;
|
|
2774
2887
|
}
|
|
2775
2888
|
stopReason = "end_turn";
|
|
2776
2889
|
break;
|
|
2777
2890
|
}
|
|
2778
2891
|
case "error_max_budget_usd":
|
|
2892
|
+
if (message.is_error) {
|
|
2893
|
+
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2894
|
+
break;
|
|
2895
|
+
}
|
|
2896
|
+
stopReason = "max_turn_requests";
|
|
2897
|
+
break;
|
|
2779
2898
|
case "error_max_turns":
|
|
2899
|
+
if (message.is_error) {
|
|
2900
|
+
await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2901
|
+
break;
|
|
2902
|
+
}
|
|
2903
|
+
stopReason = "max_turn_requests";
|
|
2904
|
+
break;
|
|
2780
2905
|
case "error_max_structured_output_retries":
|
|
2781
2906
|
if (message.is_error) {
|
|
2782
|
-
|
|
2907
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2783
2908
|
break;
|
|
2784
2909
|
}
|
|
2785
2910
|
stopReason = "max_turn_requests";
|
|
@@ -2917,6 +3042,7 @@ export class ClaudeAcpAgent {
|
|
|
2917
3042
|
const nextUsage = totalTokens(lastAssistantUsage);
|
|
2918
3043
|
if (nextUsage !== lastAssistantTotalUsage) {
|
|
2919
3044
|
lastAssistantTotalUsage = nextUsage;
|
|
3045
|
+
session.contextUsedTokens = nextUsage;
|
|
2920
3046
|
await sendUpdate({
|
|
2921
3047
|
sessionId: params.sessionId,
|
|
2922
3048
|
update: {
|
|
@@ -3053,6 +3179,11 @@ export class ClaudeAcpAgent {
|
|
|
3053
3179
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
3054
3180
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
3055
3181
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3182
|
+
session.contextUsedTokens = lastAssistantTotalUsage;
|
|
3183
|
+
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3184
|
+
if (message.error || lastAssistantWasUsageLimit) {
|
|
3185
|
+
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
3186
|
+
}
|
|
3056
3187
|
if (message.message.model && message.message.model !== "<synthetic>") {
|
|
3057
3188
|
lastAssistantModel = message.message.model;
|
|
3058
3189
|
}
|
|
@@ -3111,7 +3242,18 @@ export class ClaudeAcpAgent {
|
|
|
3111
3242
|
break;
|
|
3112
3243
|
}
|
|
3113
3244
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3114
|
-
|
|
3245
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
|
|
3246
|
+
break;
|
|
3247
|
+
}
|
|
3248
|
+
// AIR receives this provider condition on the terminal prompt
|
|
3249
|
+
// response as a typed failure whose title is the exact assistant
|
|
3250
|
+
// error text captured above. Do not duplicate that text as an
|
|
3251
|
+
// ordinary assistant message; legacy clients retain the historical
|
|
3252
|
+
// transcript behavior.
|
|
3253
|
+
if (message.type === "assistant" &&
|
|
3254
|
+
message.parent_tool_use_id === null &&
|
|
3255
|
+
(message.error || isSyntheticUsageLimitMessage(message.message)) &&
|
|
3256
|
+
supportsAirSessionFailures(this.clientCapabilities)) {
|
|
3115
3257
|
break;
|
|
3116
3258
|
}
|
|
3117
3259
|
let content;
|
|
@@ -3188,6 +3330,7 @@ export class ClaudeAcpAgent {
|
|
|
3188
3330
|
else {
|
|
3189
3331
|
content = message.message.content;
|
|
3190
3332
|
}
|
|
3333
|
+
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
|
3191
3334
|
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
3192
3335
|
clientCapabilities: this.clientCapabilities,
|
|
3193
3336
|
parentToolUseId: message.parent_tool_use_id,
|
|
@@ -3206,7 +3349,7 @@ export class ClaudeAcpAgent {
|
|
|
3206
3349
|
// filtered out of `content` above; blocks that do pass through
|
|
3207
3350
|
// (e.g. a subagent image) carry the stamped parentToolUseId
|
|
3208
3351
|
// meta and are excluded there.
|
|
3209
|
-
await sendUpdate(notification);
|
|
3352
|
+
await sendUpdate(acceptedPlanToolResult(notification, acceptedPlanToolUseId));
|
|
3210
3353
|
}
|
|
3211
3354
|
break;
|
|
3212
3355
|
}
|
|
@@ -3273,13 +3416,26 @@ export class ClaudeAcpAgent {
|
|
|
3273
3416
|
}
|
|
3274
3417
|
break;
|
|
3275
3418
|
}
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3419
|
+
case "conversation_reset": {
|
|
3420
|
+
// The SDK has switched to a fresh conversation, whose Task* IDs
|
|
3421
|
+
// and task store are independent of the previous transcript.
|
|
3422
|
+
// Clear both the in-memory snapshot and the client's visible plan
|
|
3423
|
+
// before any follow-up prompt can republish stale tasks.
|
|
3424
|
+
session.taskState.clear();
|
|
3425
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3426
|
+
// A reset mounts a fresh transcript (`new_conversation_id`), so our
|
|
3427
|
+
// cached title no longer describes the session: drop it and
|
|
3428
|
+
// re-evaluate at the next turn-end.
|
|
3429
|
+
session.titles.reset();
|
|
3430
|
+
break;
|
|
3431
|
+
}
|
|
3279
3432
|
case "tool_use_summary":
|
|
3280
|
-
case "auth_status":
|
|
3281
3433
|
case "prompt_suggestion":
|
|
3282
|
-
|
|
3434
|
+
break;
|
|
3435
|
+
case "auth_status":
|
|
3436
|
+
if (!message.isAuthenticating && message.error === undefined) {
|
|
3437
|
+
await sessionFailures.clear((failure) => failure.kind === "auth_required");
|
|
3438
|
+
}
|
|
3283
3439
|
break;
|
|
3284
3440
|
default:
|
|
3285
3441
|
unreachable(message, this.logger);
|
|
@@ -3308,6 +3464,19 @@ export class ClaudeAcpAgent {
|
|
|
3308
3464
|
message.includes("process exited with") ||
|
|
3309
3465
|
message.includes("process terminated by signal") ||
|
|
3310
3466
|
message.includes("Failed to write to process stdin"));
|
|
3467
|
+
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3468
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
3469
|
+
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
3470
|
+
}
|
|
3471
|
+
else {
|
|
3472
|
+
// The held turn keeps its recorded PromptResponse outcome, while the
|
|
3473
|
+
// exhausted Query makes the session independently unrecoverable.
|
|
3474
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
else {
|
|
3478
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3479
|
+
}
|
|
3311
3480
|
// Either way the query iterator is finished and the consumer is exiting,
|
|
3312
3481
|
// so release its resources via closeQueryStream (idempotent). A process
|
|
3313
3482
|
// death is unrecoverable, so additionally evict the session so the client
|
|
@@ -3321,7 +3490,9 @@ export class ClaudeAcpAgent {
|
|
|
3321
3490
|
}
|
|
3322
3491
|
else {
|
|
3323
3492
|
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
3324
|
-
failAllTurns(
|
|
3493
|
+
failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
|
|
3494
|
+
? internalErrorForClient({ errorKind: "transport_lost" })
|
|
3495
|
+
: error);
|
|
3325
3496
|
this.closeQueryStream(session);
|
|
3326
3497
|
}
|
|
3327
3498
|
}
|
|
@@ -3351,6 +3522,7 @@ export class ClaudeAcpAgent {
|
|
|
3351
3522
|
}
|
|
3352
3523
|
}
|
|
3353
3524
|
async cancel(params) {
|
|
3525
|
+
this.exitPlan.cancel(params.sessionId);
|
|
3354
3526
|
const session = this.sessions[params.sessionId];
|
|
3355
3527
|
if (!session) {
|
|
3356
3528
|
return;
|
|
@@ -3364,6 +3536,9 @@ export class ClaudeAcpAgent {
|
|
|
3364
3536
|
if (session.queryRecreateInFlight) {
|
|
3365
3537
|
await session.queryRecreateInFlight;
|
|
3366
3538
|
}
|
|
3539
|
+
session.cancelled = true;
|
|
3540
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3541
|
+
session.pendingExitPlanContextReset = undefined;
|
|
3367
3542
|
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
3368
3543
|
// settled when it closed, and there is no live query to interrupt. Calling
|
|
3369
3544
|
// query.interrupt() on a finished iterator could reject and surface from
|
|
@@ -3371,7 +3546,6 @@ export class ClaudeAcpAgent {
|
|
|
3371
3546
|
if (session.queryClosed) {
|
|
3372
3547
|
return;
|
|
3373
3548
|
}
|
|
3374
|
-
session.cancelled = true;
|
|
3375
3549
|
// A priority steer may still be queued in the SDK when cancellation
|
|
3376
3550
|
// settles its owning turn. Its later echo matches no live turn, and its
|
|
3377
3551
|
// result must be skipped rather than promoted onto the next prompt.
|
|
@@ -3395,6 +3569,7 @@ export class ClaudeAcpAgent {
|
|
|
3395
3569
|
if (session.turnQueue) {
|
|
3396
3570
|
for (const turn of session.turnQueue) {
|
|
3397
3571
|
if (turn !== session.activeTurn && !turn.settled) {
|
|
3572
|
+
this.finishFileChangeAudit(session, turn, "cancelled");
|
|
3398
3573
|
turn.settled = true;
|
|
3399
3574
|
// Deliberately no `usage`: a queued turn never ran, so the session
|
|
3400
3575
|
// accumulator (the active turn's tally) is not its spend.
|
|
@@ -3414,7 +3589,9 @@ export class ClaudeAcpAgent {
|
|
|
3414
3589
|
// never see lifecycle frames, so commandStarted/commandFinished stay
|
|
3415
3590
|
// unset and every turn takes the plain-seed path below).
|
|
3416
3591
|
for (const turn of orphanedTurns) {
|
|
3417
|
-
if (turn.commandFinished === "completed" ||
|
|
3592
|
+
if (turn.commandFinished === "completed" ||
|
|
3593
|
+
turn.commandFinished === "discarded" ||
|
|
3594
|
+
turn.commandFinished === "refused") {
|
|
3418
3595
|
// The command already finished SDK-side and its terminal frame was
|
|
3419
3596
|
// consumed while the turn sat queued — nothing is left to skip, and
|
|
3420
3597
|
// a seeded entry would never drain.
|
|
@@ -3456,6 +3633,7 @@ export class ClaudeAcpAgent {
|
|
|
3456
3633
|
{
|
|
3457
3634
|
const active = session.activeTurn;
|
|
3458
3635
|
if (isHeldOpen(active)) {
|
|
3636
|
+
this.finishFileChangeAudit(session, active, "cancelled");
|
|
3459
3637
|
active.settled = true;
|
|
3460
3638
|
// Mirror settleActive's invariants (it is consumer-scoped and
|
|
3461
3639
|
// unreachable from here): disarm the backstop — none should be
|
|
@@ -3639,27 +3817,19 @@ export class ClaudeAcpAgent {
|
|
|
3639
3817
|
return {};
|
|
3640
3818
|
}
|
|
3641
3819
|
async setSessionMode(params) {
|
|
3642
|
-
const session = this.sessions[params.sessionId];
|
|
3643
|
-
if (!session) {
|
|
3644
|
-
throw new Error("Session not found");
|
|
3645
|
-
}
|
|
3646
3820
|
// A lazy Thinking recreate may be swapping the session's query right now;
|
|
3647
3821
|
// join it (mirroring prompt()) so `setPermissionMode` below lands on the
|
|
3648
3822
|
// replacement query after cutover instead of an about-to-close one — and
|
|
3649
3823
|
// so the mode isn't silently dropped from a query built from the
|
|
3650
3824
|
// pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
|
|
3651
|
-
|
|
3825
|
+
// The session-not-found and SESSION_ENDED guards the fork inlined here now
|
|
3826
|
+
// live in `SessionModeManager.requireOpenSession`, which raises the same
|
|
3827
|
+
// errors, so they are not duplicated in front of the delegation below.
|
|
3828
|
+
const session = this.sessions[params.sessionId];
|
|
3829
|
+
if (session?.queryRecreateInFlight) {
|
|
3652
3830
|
await session.queryRecreateInFlight;
|
|
3653
3831
|
}
|
|
3654
|
-
|
|
3655
|
-
// a husk and `query.setPermissionMode` below would act on a closed query.
|
|
3656
|
-
// Fail with the same clear message prompt()/cancel() give for a dead stream.
|
|
3657
|
-
if (session.queryClosed) {
|
|
3658
|
-
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
3659
|
-
}
|
|
3660
|
-
await this.applySessionMode(params.sessionId, params.modeId);
|
|
3661
|
-
await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
|
|
3662
|
-
return {};
|
|
3832
|
+
return this.sessionModes.setSessionMode(params);
|
|
3663
3833
|
}
|
|
3664
3834
|
async setSessionConfigOption(params) {
|
|
3665
3835
|
const session = this.sessions[params.sessionId];
|
|
@@ -3687,10 +3857,9 @@ export class ClaudeAcpAgent {
|
|
|
3687
3857
|
if (!option) {
|
|
3688
3858
|
throw new Error(`Unknown config option: ${params.configId}`);
|
|
3689
3859
|
}
|
|
3690
|
-
// Fast mode
|
|
3691
|
-
//
|
|
3692
|
-
//
|
|
3693
|
-
// rely on.
|
|
3860
|
+
// Fast mode carries a boolean value (for Clients that opted into boolean
|
|
3861
|
+
// config options) or the "on"/"off" select fallback, so it bypasses the
|
|
3862
|
+
// string-only validation the select-style options below rely on.
|
|
3694
3863
|
if (params.configId === FAST_MODE_CONFIG_ID) {
|
|
3695
3864
|
await this.applyFastMode(session, resolveFastModeEnabled(params));
|
|
3696
3865
|
return { configOptions: session.configOptions };
|
|
@@ -3759,15 +3928,10 @@ export class ClaudeAcpAgent {
|
|
|
3759
3928
|
// Use the canonical option value so downstream code always receives the
|
|
3760
3929
|
// model ID rather than the caller-supplied alias.
|
|
3761
3930
|
const resolvedValue = validValue.value;
|
|
3931
|
+
let effectiveValue = resolvedValue;
|
|
3762
3932
|
if (params.configId === MODE_CONFIG_ID) {
|
|
3763
|
-
await this.
|
|
3764
|
-
await this.
|
|
3765
|
-
sessionId: params.sessionId,
|
|
3766
|
-
update: {
|
|
3767
|
-
sessionUpdate: "current_mode_update",
|
|
3768
|
-
currentModeId: resolvedValue,
|
|
3769
|
-
},
|
|
3770
|
-
});
|
|
3933
|
+
effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
|
|
3934
|
+
await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
|
3771
3935
|
}
|
|
3772
3936
|
else if (params.configId === MODEL_CONFIG_ID) {
|
|
3773
3937
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
@@ -3775,50 +3939,41 @@ export class ClaudeAcpAgent {
|
|
|
3775
3939
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
3776
3940
|
// effort changes and effort changes induced by a model switch go through
|
|
3777
3941
|
// the same path.
|
|
3778
|
-
await this.applyConfigOptionValue(params.sessionId, session, params.configId,
|
|
3942
|
+
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
|
3779
3943
|
return { configOptions: session.configOptions };
|
|
3780
3944
|
}
|
|
3781
|
-
async applySessionMode(sessionId, modeId) {
|
|
3782
|
-
switch (modeId) {
|
|
3783
|
-
case "auto":
|
|
3784
|
-
case "default":
|
|
3785
|
-
case "acceptEdits":
|
|
3786
|
-
case "bypassPermissions":
|
|
3787
|
-
case "dontAsk":
|
|
3788
|
-
case "plan":
|
|
3789
|
-
break;
|
|
3790
|
-
default:
|
|
3791
|
-
throw new Error("Invalid Mode");
|
|
3792
|
-
}
|
|
3793
|
-
const session = this.sessions[sessionId];
|
|
3794
|
-
if (!session) {
|
|
3795
|
-
throw new Error("Session not found");
|
|
3796
|
-
}
|
|
3797
|
-
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
|
|
3798
|
-
throw new Error(`Mode ${modeId} is not available in this session`);
|
|
3799
|
-
}
|
|
3800
|
-
try {
|
|
3801
|
-
await session.query.setPermissionMode(modeId);
|
|
3802
|
-
}
|
|
3803
|
-
catch (error) {
|
|
3804
|
-
if (error instanceof Error) {
|
|
3805
|
-
if (!error.message) {
|
|
3806
|
-
error.message = "Invalid Mode";
|
|
3807
|
-
}
|
|
3808
|
-
throw error;
|
|
3809
|
-
}
|
|
3810
|
-
else {
|
|
3811
|
-
// eslint-disable-next-line preserve-caught-error
|
|
3812
|
-
throw new Error("Invalid Mode");
|
|
3813
|
-
}
|
|
3814
|
-
}
|
|
3815
|
-
}
|
|
3816
3945
|
async replaySessionHistory(sessionId) {
|
|
3817
3946
|
const toolUseCache = {};
|
|
3818
3947
|
const messages = await getSessionMessages(sessionId);
|
|
3819
|
-
const
|
|
3820
|
-
|
|
3948
|
+
const session = this.sessions[sessionId];
|
|
3949
|
+
const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
|
|
3950
|
+
const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
|
|
3951
|
+
const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
|
|
3952
|
+
const sessionFailures = session && supportsTypedFailures
|
|
3953
|
+
? new SessionFailureController({
|
|
3954
|
+
sessionId,
|
|
3955
|
+
state: session.sessionFailureState,
|
|
3956
|
+
capabilities: this.clientCapabilities,
|
|
3957
|
+
isCurrent: () => this.sessions[sessionId] === session,
|
|
3958
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
3959
|
+
logger: this.logger,
|
|
3960
|
+
})
|
|
3961
|
+
: undefined;
|
|
3962
|
+
let replayTurnId;
|
|
3963
|
+
// Stop-hook additionalContext is persisted as an internal user message.
|
|
3964
|
+
// Once that marker (or the internal tool itself) appears, suppress the
|
|
3965
|
+
// whole audit exchange until its tool result. This also hides a disobedient
|
|
3966
|
+
// model's separate prose message, while an ordinary next user prompt safely
|
|
3967
|
+
// ends an incomplete audit lane.
|
|
3968
|
+
let replayingFileChangeAudit = false;
|
|
3969
|
+
const replayFileChangeAuditToolUseIds = new Set();
|
|
3821
3970
|
for (const message of messages) {
|
|
3971
|
+
if (message.type === "user" &&
|
|
3972
|
+
message.parent_tool_use_id === null &&
|
|
3973
|
+
typeof message.uuid === "string" &&
|
|
3974
|
+
message.uuid.length > 0) {
|
|
3975
|
+
replayTurnId = message.uuid;
|
|
3976
|
+
}
|
|
3822
3977
|
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
3823
3978
|
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
3824
3979
|
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
@@ -3834,6 +3989,21 @@ export class ClaudeAcpAgent {
|
|
|
3834
3989
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3835
3990
|
continue;
|
|
3836
3991
|
}
|
|
3992
|
+
// Capable clients saw every synthetic usage-limit message as a typed
|
|
3993
|
+
// failure live, so replay it at the same transcript position. The
|
|
3994
|
+
// preceding persisted user uuid is the live prompt uuid and therefore
|
|
3995
|
+
// recreates the same incident identity. Only the latest limit not
|
|
3996
|
+
// followed by a real model answer remains active internally.
|
|
3997
|
+
if (sessionFailures &&
|
|
3998
|
+
message.type === "assistant" &&
|
|
3999
|
+
message.parent_tool_use_id === null &&
|
|
4000
|
+
isSyntheticUsageLimitMessage(message.message)) {
|
|
4001
|
+
const title = assistantMessageText(message.message);
|
|
4002
|
+
if (title) {
|
|
4003
|
+
await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
|
|
4004
|
+
}
|
|
4005
|
+
continue;
|
|
4006
|
+
}
|
|
3837
4007
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3838
4008
|
let content = message.message.content;
|
|
3839
4009
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
@@ -3846,6 +4016,57 @@ export class ClaudeAcpAgent {
|
|
|
3846
4016
|
if (content === null)
|
|
3847
4017
|
continue;
|
|
3848
4018
|
}
|
|
4019
|
+
const auditBlocks = Array.isArray(content)
|
|
4020
|
+
? content.filter((block) => typeof block === "object" && block !== null)
|
|
4021
|
+
: [];
|
|
4022
|
+
const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
|
|
4023
|
+
auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
|
|
4024
|
+
const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
|
|
4025
|
+
block.type === "server_tool_use" ||
|
|
4026
|
+
block.type === "mcp_tool_use") &&
|
|
4027
|
+
typeof block.name === "string" &&
|
|
4028
|
+
isFileChangeAuditTool(block.name) &&
|
|
4029
|
+
typeof block.id === "string"
|
|
4030
|
+
? [block.id]
|
|
4031
|
+
: []);
|
|
4032
|
+
const replayMessageRole = message.message
|
|
4033
|
+
?.role;
|
|
4034
|
+
if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
|
|
4035
|
+
replayingFileChangeAudit = true;
|
|
4036
|
+
for (const toolUseId of fileChangeAuditToolUseIds) {
|
|
4037
|
+
replayFileChangeAuditToolUseIds.add(toolUseId);
|
|
4038
|
+
}
|
|
4039
|
+
continue;
|
|
4040
|
+
}
|
|
4041
|
+
if (replayingFileChangeAudit) {
|
|
4042
|
+
const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
|
|
4043
|
+
typeof block.tool_use_id === "string"
|
|
4044
|
+
? [block.tool_use_id]
|
|
4045
|
+
: []);
|
|
4046
|
+
let completedReport = false;
|
|
4047
|
+
for (const toolUseId of toolResultIds) {
|
|
4048
|
+
if (replayFileChangeAuditToolUseIds.delete(toolUseId))
|
|
4049
|
+
completedReport = true;
|
|
4050
|
+
}
|
|
4051
|
+
if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
|
|
4052
|
+
replayingFileChangeAudit = false;
|
|
4053
|
+
continue;
|
|
4054
|
+
}
|
|
4055
|
+
// A denied attempt to call another tool is still part of the hidden
|
|
4056
|
+
// lane. Its result must not end replay suppression before the report.
|
|
4057
|
+
if (toolResultIds.length > 0) {
|
|
4058
|
+
continue;
|
|
4059
|
+
}
|
|
4060
|
+
// The next real user prompt is already represented by the client and
|
|
4061
|
+
// starts a new turn; do not let a missing audit result hide it or the
|
|
4062
|
+
// rest of the replay.
|
|
4063
|
+
if (replayMessageRole === "user") {
|
|
4064
|
+
replayingFileChangeAudit = false;
|
|
4065
|
+
}
|
|
4066
|
+
else {
|
|
4067
|
+
continue;
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
3849
4070
|
for (const notification of toAcpNotifications(
|
|
3850
4071
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3851
4072
|
content,
|
|
@@ -3873,20 +4094,26 @@ export class ClaudeAcpAgent {
|
|
|
3873
4094
|
/** Forward a permission request to the client, wiring the tool call's
|
|
3874
4095
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
3875
4096
|
* while the client's prompt is still open the signal aborts, the SDK sends
|
|
3876
|
-
* `$/cancel_request`, and
|
|
3877
|
-
*
|
|
3878
|
-
* "Tool use aborted" the callers already expect
|
|
3879
|
-
* longer leaves the `await` hanging. */
|
|
4097
|
+
* `$/cancel_request`, and our local abort race settles even if the client
|
|
4098
|
+
* ignores it. A `cancelled` outcome, request rejection, and local abort all
|
|
4099
|
+
* surface the same "Tool use aborted" the callers already expect. */
|
|
3880
4100
|
async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
|
|
4101
|
+
if (signal.aborted)
|
|
4102
|
+
throw new Error("Tool use aborted");
|
|
3881
4103
|
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
3882
4104
|
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
3883
4105
|
// expect the `tool_call` a permission request references to already exist,
|
|
3884
4106
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
3885
4107
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
3886
4108
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
3887
|
-
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
|
|
4109
|
+
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
|
|
4110
|
+
if (signal.aborted)
|
|
4111
|
+
throw new Error("Tool use aborted");
|
|
4112
|
+
// Do not rely on every ACP client settling requestPermission after the
|
|
4113
|
+
// cancellation signal. The local race guarantees that Claude's tool call
|
|
4114
|
+
// is released even when an older or broken client ignores $/cancel_request.
|
|
3888
4115
|
try {
|
|
3889
|
-
return await this.client.requestPermission(params, signal);
|
|
4116
|
+
return await raceWithAbort(this.client.requestPermission(params, signal), signal);
|
|
3890
4117
|
}
|
|
3891
4118
|
catch (error) {
|
|
3892
4119
|
if (signal.aborted) {
|
|
@@ -3907,7 +4134,7 @@ export class ClaudeAcpAgent {
|
|
|
3907
4134
|
* are resolved at tool_result time instead (see `toAcpNotifications`).
|
|
3908
4135
|
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
|
|
3909
4136
|
* that spawned it, matching the streamed path's `_meta`. */
|
|
3910
|
-
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
|
|
4137
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal) {
|
|
3911
4138
|
const session = this.sessions[sessionId];
|
|
3912
4139
|
if (!session) {
|
|
3913
4140
|
return;
|
|
@@ -3927,10 +4154,20 @@ export class ClaudeAcpAgent {
|
|
|
3927
4154
|
},
|
|
3928
4155
|
};
|
|
3929
4156
|
}
|
|
3930
|
-
|
|
4157
|
+
try {
|
|
4158
|
+
const emission = this.client.sessionUpdate({ sessionId, update });
|
|
4159
|
+
await (signal ? raceWithAbort(emission, signal) : emission);
|
|
4160
|
+
}
|
|
4161
|
+
catch (error) {
|
|
4162
|
+
// The set is also the de-duplication guard for the later streamed
|
|
4163
|
+
// tool_use. Keep it truthful: if emission failed, that path must still
|
|
4164
|
+
// be allowed to publish the tool call instead of refining a phantom one.
|
|
4165
|
+
session.emittedToolCalls.delete(toolCallId);
|
|
4166
|
+
throw error;
|
|
4167
|
+
}
|
|
3931
4168
|
}
|
|
3932
4169
|
canUseTool(sessionId) {
|
|
3933
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
|
|
4170
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
|
|
3934
4171
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
3935
4172
|
const session = this.sessions[sessionId];
|
|
3936
4173
|
if (!session) {
|
|
@@ -3939,6 +4176,26 @@ export class ClaudeAcpAgent {
|
|
|
3939
4176
|
message: "Session not found",
|
|
3940
4177
|
};
|
|
3941
4178
|
}
|
|
4179
|
+
const fileChangeAudit = session.activeTurn?.fileChangeAudit;
|
|
4180
|
+
if (isFileChangeAuditReportPhase(fileChangeAudit)) {
|
|
4181
|
+
// The hidden continuation is an audit-only lane: it may submit the
|
|
4182
|
+
// wrapper-owned report, but it must not run another command after the
|
|
4183
|
+
// user-visible answer has already completed.
|
|
4184
|
+
if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
|
|
4185
|
+
return { behavior: "allow", updatedInput: toolInput };
|
|
4186
|
+
}
|
|
4187
|
+
return {
|
|
4188
|
+
behavior: "deny",
|
|
4189
|
+
message: "Only the internal file-change report is allowed during the audit.",
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
// The tool is intentionally unusable outside a negotiated audit turn.
|
|
4193
|
+
if (isFileChangeAuditTool(toolName)) {
|
|
4194
|
+
return {
|
|
4195
|
+
behavior: "deny",
|
|
4196
|
+
message: "No file-change report was requested for this turn.",
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
3942
4199
|
// When the tool call originates inside a subagent, attribute the eagerly
|
|
3943
4200
|
// emitted tool_call (and the permission request itself) to the Agent/Task
|
|
3944
4201
|
// tool call that spawned the subagent, mirroring the streamed subagent
|
|
@@ -3963,155 +4220,81 @@ export class ClaudeAcpAgent {
|
|
|
3963
4220
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
3964
4221
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
3965
4222
|
// make sure the tool_call has surfaced to the client before we send it.
|
|
3966
|
-
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
|
|
4223
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
|
|
3967
4224
|
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
3968
4225
|
}
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
// "keep planning" reject path; it's always present in `availableModes`.
|
|
3992
|
-
const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
|
|
3993
|
-
const response = await this.requestPermissionFromClient({
|
|
3994
|
-
options,
|
|
3995
|
-
sessionId,
|
|
3996
|
-
toolCall: {
|
|
3997
|
-
toolCallId: toolUseID,
|
|
3998
|
-
rawInput: toolInput,
|
|
3999
|
-
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
4000
|
-
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
4001
|
-
// so clients can rely on one shape everywhere.
|
|
4002
|
-
...(parentToolUseId
|
|
4003
|
-
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
4004
|
-
: {}),
|
|
4005
|
-
},
|
|
4006
|
-
}, toolName, signal, parentToolUseId);
|
|
4007
|
-
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
4008
|
-
throw new Error("Tool use aborted");
|
|
4009
|
-
}
|
|
4010
|
-
const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
|
|
4011
|
-
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
|
|
4012
|
-
if (selectedModeWasOffered &&
|
|
4013
|
-
(selectedMode === "default" ||
|
|
4014
|
-
selectedMode === "acceptEdits" ||
|
|
4015
|
-
selectedMode === "auto" ||
|
|
4016
|
-
selectedMode === "bypassPermissions")) {
|
|
4017
|
-
await this.client.sessionUpdate({
|
|
4018
|
-
sessionId,
|
|
4019
|
-
update: {
|
|
4020
|
-
sessionUpdate: "current_mode_update",
|
|
4021
|
-
currentModeId: selectedMode,
|
|
4022
|
-
},
|
|
4023
|
-
});
|
|
4024
|
-
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
|
|
4025
|
-
return {
|
|
4026
|
-
behavior: "allow",
|
|
4027
|
-
updatedInput: toolInput,
|
|
4028
|
-
updatedPermissions: suggestions ?? [
|
|
4029
|
-
{ type: "setMode", mode: selectedMode, destination: "session" },
|
|
4030
|
-
],
|
|
4031
|
-
};
|
|
4032
|
-
}
|
|
4033
|
-
else {
|
|
4034
|
-
return {
|
|
4035
|
-
behavior: "deny",
|
|
4036
|
-
message: "User rejected request to exit plan mode.",
|
|
4037
|
-
};
|
|
4038
|
-
}
|
|
4039
|
-
}
|
|
4040
|
-
// In bypass mode the CLI skips permission checks itself; the asks that
|
|
4041
|
-
// still reach canUseTool are the ones it insists on prompting for even
|
|
4042
|
-
// under --dangerously-skip-permissions. Keep auto-allowing those —
|
|
4043
|
-
// bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
|
|
4044
|
-
// user explicitly configured a permissions.ask rule for this tool, and
|
|
4045
|
-
// the SDK's guidance is that hosts running auto-approval must treat such
|
|
4046
|
-
// asks as a human prompt. Fall through to the normal request below.
|
|
4047
|
-
if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
|
|
4048
|
-
return {
|
|
4049
|
-
behavior: "allow",
|
|
4050
|
-
updatedInput: toolInput,
|
|
4051
|
-
updatedPermissions: suggestions ?? [
|
|
4052
|
-
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
|
4053
|
-
],
|
|
4226
|
+
// Do not auto-allow here based on the session's advertised mode. Claude
|
|
4227
|
+
// Code applies bypassPermissions before invoking canUseTool; a request
|
|
4228
|
+
// that still reaches this callback is deliberately bypass-immune (for
|
|
4229
|
+
// example a safety check, a tool requiring user interaction, or an
|
|
4230
|
+
// explicit ask rule). Re-applying bypass in the host would erase that
|
|
4231
|
+
// provider safety decision.
|
|
4232
|
+
const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
|
|
4233
|
+
const presentation = buildClaudePermissionPresentation({
|
|
4234
|
+
toolName,
|
|
4235
|
+
input: toolInput,
|
|
4236
|
+
toolUseID,
|
|
4237
|
+
cwd: session.cwd,
|
|
4238
|
+
supportsTerminalOutput,
|
|
4239
|
+
blockedPath,
|
|
4240
|
+
title,
|
|
4241
|
+
displayName,
|
|
4242
|
+
description,
|
|
4243
|
+
decisionReason,
|
|
4244
|
+
});
|
|
4245
|
+
if (parentToolUseId) {
|
|
4246
|
+
presentation.toolCall._meta = {
|
|
4247
|
+
claudeCode: { toolName, parentToolUseId },
|
|
4054
4248
|
};
|
|
4055
4249
|
}
|
|
4250
|
+
const permissionOptions = buildClaudePermissionOptions({
|
|
4251
|
+
toolName,
|
|
4252
|
+
displayName,
|
|
4253
|
+
input: toolInput,
|
|
4254
|
+
cwd: session.cwd,
|
|
4255
|
+
durableChangeSet,
|
|
4256
|
+
allowPersistentOptions: matchedAskRule === undefined,
|
|
4257
|
+
availableModes: this.sessionModes.availableModeIds(session.modes),
|
|
4258
|
+
contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
|
4259
|
+
? undefined
|
|
4260
|
+
: Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
|
|
4261
|
+
});
|
|
4056
4262
|
const response = await this.requestPermissionFromClient({
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
{ kind: "allow_once", name: "Allow Once", optionId: "allow" },
|
|
4060
|
-
{
|
|
4061
|
-
// The label discloses the scope for clients that render only the
|
|
4062
|
-
// option name; `_meta.permission` carries the same commitment in
|
|
4063
|
-
// the structured upstream form. See `describeAlwaysAllow`.
|
|
4064
|
-
kind: "allow_always",
|
|
4065
|
-
name: describeAlwaysAllow(suggestions, toolName),
|
|
4066
|
-
optionId: "allow_always",
|
|
4067
|
-
_meta: {
|
|
4068
|
-
permission: permissionMetadataForAlwaysAllow(suggestions, toolName),
|
|
4069
|
-
},
|
|
4070
|
-
},
|
|
4071
|
-
],
|
|
4263
|
+
...presentation,
|
|
4264
|
+
options: permissionOptions,
|
|
4072
4265
|
sessionId,
|
|
4073
|
-
toolCall: {
|
|
4074
|
-
toolCallId: toolUseID,
|
|
4075
|
-
rawInput: toolInput,
|
|
4076
|
-
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
4077
|
-
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
4078
|
-
// so clients can rely on one shape everywhere.
|
|
4079
|
-
...(parentToolUseId
|
|
4080
|
-
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
4081
|
-
: {}),
|
|
4082
|
-
},
|
|
4083
4266
|
}, toolName, signal, parentToolUseId);
|
|
4084
|
-
if (signal.aborted
|
|
4267
|
+
if (signal.aborted)
|
|
4085
4268
|
throw new Error("Tool use aborted");
|
|
4269
|
+
const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
|
|
4270
|
+
let permissionResult = decodedPermission.permissionResult;
|
|
4271
|
+
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
|
|
4272
|
+
permissionResult = autoFallback.permissionResult;
|
|
4273
|
+
if (autoFallback.fallbackApplied) {
|
|
4274
|
+
await this.sessionModes.publishFallbackWarning(sessionId, session);
|
|
4086
4275
|
}
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
behavior: "allow",
|
|
4099
|
-
destination: "session",
|
|
4100
|
-
},
|
|
4101
|
-
],
|
|
4102
|
-
};
|
|
4103
|
-
}
|
|
4104
|
-
return {
|
|
4105
|
-
behavior: "allow",
|
|
4106
|
-
updatedInput: toolInput,
|
|
4276
|
+
const clearContextMode = decodedPermission.contextResetMode
|
|
4277
|
+
? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
|
|
4278
|
+
: undefined;
|
|
4279
|
+
if (toolName === "ExitPlanMode" && clearContextMode) {
|
|
4280
|
+
const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
|
|
4281
|
+
if (!plan)
|
|
4282
|
+
throw new Error("ExitPlanMode clear-context selection requires a plan");
|
|
4283
|
+
session.pendingExitPlanContextReset = {
|
|
4284
|
+
toolUseId: toolUseID,
|
|
4285
|
+
plan,
|
|
4286
|
+
mode: clearContextMode,
|
|
4107
4287
|
};
|
|
4108
4288
|
}
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4289
|
+
if (toolName === "ExitPlanMode" &&
|
|
4290
|
+
permissionResult.behavior === "deny" &&
|
|
4291
|
+
permissionResult.interrupt === true) {
|
|
4292
|
+
session.pendingExitPlanModeInterruption = {
|
|
4293
|
+
toolUseId: toolUseID,
|
|
4294
|
+
toolResultSeen: false,
|
|
4113
4295
|
};
|
|
4114
4296
|
}
|
|
4297
|
+
return permissionResult;
|
|
4115
4298
|
};
|
|
4116
4299
|
}
|
|
4117
4300
|
/**
|
|
@@ -4227,7 +4410,7 @@ export class ClaudeAcpAgent {
|
|
|
4227
4410
|
sessionId,
|
|
4228
4411
|
update: {
|
|
4229
4412
|
sessionUpdate: "available_commands_update",
|
|
4230
|
-
availableCommands: getAvailableSlashCommands(commands),
|
|
4413
|
+
availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
|
|
4231
4414
|
},
|
|
4232
4415
|
});
|
|
4233
4416
|
}
|
|
@@ -4246,12 +4429,11 @@ export class ClaudeAcpAgent {
|
|
|
4246
4429
|
}
|
|
4247
4430
|
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
4248
4431
|
if (configId === MODE_CONFIG_ID) {
|
|
4249
|
-
|
|
4250
|
-
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4432
|
+
this.sessionModes.syncConfig(session, value);
|
|
4251
4433
|
}
|
|
4252
4434
|
else if (configId === MODEL_CONFIG_ID) {
|
|
4253
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for
|
|
4254
|
-
//
|
|
4435
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
|
|
4436
|
+
// the Auto fallback below; its `displayName`/`description` also let us infer the
|
|
4255
4437
|
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
4256
4438
|
// carries no "1m" token.
|
|
4257
4439
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
@@ -4273,41 +4455,7 @@ export class ClaudeAcpAgent {
|
|
|
4273
4455
|
session.contextWindowAuthoritative = seeded.authoritative;
|
|
4274
4456
|
}
|
|
4275
4457
|
session.models = { ...session.models, currentModelId: value };
|
|
4276
|
-
|
|
4277
|
-
// mode if the SDK no longer offers it (today: "auto" on Haiku). An
|
|
4278
|
-
// unknown model (an SDK-initiated refusal fallback to a model outside
|
|
4279
|
-
// the user's `availableModels` allowlist — user-driven switches are
|
|
4280
|
-
// validated against the options first) tells us nothing about its
|
|
4281
|
-
// capabilities, so keep the current modes rather than spuriously
|
|
4282
|
-
// downgrading (e.g. kicking the user out of "auto" for a model that
|
|
4283
|
-
// does support it).
|
|
4284
|
-
const newAvailableModes = newModelInfo
|
|
4285
|
-
? buildAvailableModes(newModelInfo)
|
|
4286
|
-
: session.modes.availableModes;
|
|
4287
|
-
// Capture BEFORE mutating session.modes so the log message reflects
|
|
4288
|
-
// the invalidated mode rather than "default".
|
|
4289
|
-
const previousModeId = session.modes.currentModeId;
|
|
4290
|
-
let modeDowngraded = false;
|
|
4291
|
-
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
|
|
4292
|
-
session.modes = {
|
|
4293
|
-
availableModes: newAvailableModes,
|
|
4294
|
-
currentModeId: "default",
|
|
4295
|
-
};
|
|
4296
|
-
try {
|
|
4297
|
-
await session.query.setPermissionMode("default");
|
|
4298
|
-
}
|
|
4299
|
-
catch (err) {
|
|
4300
|
-
// Failing the entire model switch over a bookkeeping sync error is
|
|
4301
|
-
// worse UX than logging and continuing; the user explicitly asked
|
|
4302
|
-
// to change models. The next setPermissionMode from the user will
|
|
4303
|
-
// either succeed or surface a fresh error.
|
|
4304
|
-
this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
|
|
4305
|
-
}
|
|
4306
|
-
modeDowngraded = true;
|
|
4307
|
-
}
|
|
4308
|
-
else {
|
|
4309
|
-
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
|
4310
|
-
}
|
|
4458
|
+
const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
|
|
4311
4459
|
// `model_not_allowed` described the model we just left, so it must not
|
|
4312
4460
|
// follow us onto the new one; the remaining reasons are account- or
|
|
4313
4461
|
// environment-scoped and stay true across a switch. Either way the next
|
|
@@ -4324,6 +4472,7 @@ export class ClaudeAcpAgent {
|
|
|
4324
4472
|
// intent) when a supporting model is selected again.
|
|
4325
4473
|
supported: newModelInfo?.supportsFastMode ?? false,
|
|
4326
4474
|
enabled: session.fastModeEnabled,
|
|
4475
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
4327
4476
|
disabledReason: session.fastModeDisabledReason,
|
|
4328
4477
|
},
|
|
4329
4478
|
// Thinking is model-independent: re-render the retained tri-state
|
|
@@ -4349,13 +4498,7 @@ export class ClaudeAcpAgent {
|
|
|
4349
4498
|
// still precedes the caller's config_option_update so order-sensitive
|
|
4350
4499
|
// clients update currentModeId before re-rendering the option list.
|
|
4351
4500
|
if (modeDowngraded) {
|
|
4352
|
-
await this.
|
|
4353
|
-
sessionId,
|
|
4354
|
-
update: {
|
|
4355
|
-
sessionUpdate: "current_mode_update",
|
|
4356
|
-
currentModeId: "default",
|
|
4357
|
-
},
|
|
4358
|
-
});
|
|
4501
|
+
await this.sessionModes.publishFallbackState(sessionId, session);
|
|
4359
4502
|
}
|
|
4360
4503
|
}
|
|
4361
4504
|
else if (configId === AGENT_CONFIG_ID) {
|
|
@@ -4410,13 +4553,11 @@ export class ClaudeAcpAgent {
|
|
|
4410
4553
|
}
|
|
4411
4554
|
}
|
|
4412
4555
|
/** Replace the Fast mode option in `session.configOptions` so it reflects
|
|
4413
|
-
* `enabled` (and the
|
|
4556
|
+
* `enabled` (and the client's current boolean-capability). A no-op when the
|
|
4414
4557
|
* option isn't present, so callers must confirm the current model surfaces
|
|
4415
|
-
* it first.
|
|
4416
|
-
* source of the option's shape — so the shape can't drift from what
|
|
4417
|
-
* `buildConfigOptions` first emitted. */
|
|
4558
|
+
* it first. */
|
|
4418
4559
|
refreshFastModeOption(session, enabled) {
|
|
4419
|
-
const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
|
|
4560
|
+
const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities), session.fastModeDisabledReason);
|
|
4420
4561
|
session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
|
|
4421
4562
|
}
|
|
4422
4563
|
/** Toggle Fast mode for a session: push the SDK flag, record the user's
|
|
@@ -4813,7 +4954,10 @@ export class ClaudeAcpAgent {
|
|
|
4813
4954
|
// We want to create a new session id unless it is resume,
|
|
4814
4955
|
// but not resume + forkSession.
|
|
4815
4956
|
let sessionId;
|
|
4816
|
-
if (creationOpts.
|
|
4957
|
+
if (creationOpts.publicSessionId) {
|
|
4958
|
+
sessionId = creationOpts.publicSessionId;
|
|
4959
|
+
}
|
|
4960
|
+
else if (creationOpts.forkSession) {
|
|
4817
4961
|
sessionId = randomUUID();
|
|
4818
4962
|
}
|
|
4819
4963
|
else if (creationOpts.resume) {
|
|
@@ -4872,6 +5016,7 @@ export class ClaudeAcpAgent {
|
|
|
4872
5016
|
}
|
|
4873
5017
|
}
|
|
4874
5018
|
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
|
|
5019
|
+
const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
|
|
4875
5020
|
// Extract options from _meta if provided
|
|
4876
5021
|
const sessionMeta = params._meta;
|
|
4877
5022
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
@@ -4906,6 +5051,30 @@ export class ClaudeAcpAgent {
|
|
|
4906
5051
|
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
|
4907
5052
|
// the same Map that the streaming message handler will read from.
|
|
4908
5053
|
const taskState = new Map();
|
|
5054
|
+
// Resolve every workspace root once. The hidden report tool uses this same
|
|
5055
|
+
// set for lexical path validation, and the SDK receives it below.
|
|
5056
|
+
const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
|
5057
|
+
const additionalDirectories = [
|
|
5058
|
+
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
5059
|
+
...acpAdditionalDirectories,
|
|
5060
|
+
];
|
|
5061
|
+
const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
5062
|
+
? createFileChangeAuditSupport({
|
|
5063
|
+
cwd: params.cwd,
|
|
5064
|
+
additionalDirectories,
|
|
5065
|
+
getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
|
|
5066
|
+
publish: async (result) => {
|
|
5067
|
+
await this.client.sessionUpdate({
|
|
5068
|
+
sessionId,
|
|
5069
|
+
update: {
|
|
5070
|
+
sessionUpdate: "session_info_update",
|
|
5071
|
+
_meta: agentFileChangeReportMeta(result),
|
|
5072
|
+
},
|
|
5073
|
+
});
|
|
5074
|
+
},
|
|
5075
|
+
logError: (message) => this.logger.error(message),
|
|
5076
|
+
})
|
|
5077
|
+
: undefined;
|
|
4909
5078
|
// The exact env the query will be created with. Built (and the provider
|
|
4910
5079
|
// cache key derived from it, below) in one place so the key always
|
|
4911
5080
|
// describes the backend this query actually talks to: `providers/set`,
|
|
@@ -4913,13 +5082,36 @@ export class ClaudeAcpAgent {
|
|
|
4913
5082
|
// config concurrently, so re-resolving it after any of the awaits between
|
|
4914
5083
|
// here and the session registration could disagree with the env baked
|
|
4915
5084
|
// into the query.
|
|
5085
|
+
const resolvedProvider = this.resolveProviderConfig();
|
|
5086
|
+
const providerEnv = createEnvForProvider(resolvedProvider);
|
|
5087
|
+
const configuredSettings = userProvidedOptions?.settings ??
|
|
5088
|
+
(modelConfig
|
|
5089
|
+
? {
|
|
5090
|
+
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
5091
|
+
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
5092
|
+
}
|
|
5093
|
+
: undefined);
|
|
5094
|
+
// Claude Code applies env from settings.json after the subprocess env. Put
|
|
5095
|
+
// an active ACP route in the programmatic settings tier too so user/project
|
|
5096
|
+
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
|
5097
|
+
let settings = configuredSettings;
|
|
5098
|
+
if (resolvedProvider) {
|
|
5099
|
+
const baseSettings = typeof configuredSettings === "string"
|
|
5100
|
+
? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
|
|
5101
|
+
: configuredSettings;
|
|
5102
|
+
settings = {
|
|
5103
|
+
...baseSettings,
|
|
5104
|
+
apiKeyHelper: "",
|
|
5105
|
+
env: { ...baseSettings?.env, ...providerEnv },
|
|
5106
|
+
};
|
|
5107
|
+
}
|
|
4916
5108
|
const env = {
|
|
4917
5109
|
...process.env,
|
|
4918
5110
|
...userProvidedOptions?.env,
|
|
4919
5111
|
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
4920
|
-
// legacy gateway auth request.
|
|
4921
|
-
//
|
|
4922
|
-
...
|
|
5112
|
+
// legacy gateway auth request. Routing is baked into the query at
|
|
5113
|
+
// creation; provider updates recreate loaded queries between turns.
|
|
5114
|
+
...providerEnv,
|
|
4923
5115
|
// Opt-in to session state events like when the agent is idle
|
|
4924
5116
|
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
4925
5117
|
};
|
|
@@ -4928,6 +5120,7 @@ export class ClaudeAcpAgent {
|
|
|
4928
5120
|
// SDK, so per-session `_meta` env routing and ambient process-env routing
|
|
4929
5121
|
// are distinguished exactly as the CLI will see them.
|
|
4930
5122
|
const providerCacheKey = providerCacheKeyFor(env);
|
|
5123
|
+
this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
|
|
4931
5124
|
const options = {
|
|
4932
5125
|
systemPrompt,
|
|
4933
5126
|
settingSources: ["user", "project", "local"],
|
|
@@ -4937,27 +5130,23 @@ export class ClaudeAcpAgent {
|
|
|
4937
5130
|
// `enableFileCheckpointing` still wins.
|
|
4938
5131
|
enableFileCheckpointing: true,
|
|
4939
5132
|
...userProvidedOptions,
|
|
4940
|
-
|
|
4941
|
-
// configuration (e.g. Bedrock model ID overrides). When the caller
|
|
4942
|
-
// provides settings via _meta, we intentionally ignore the env var —
|
|
4943
|
-
// the caller is assumed to have full control over model configuration.
|
|
4944
|
-
...(!userProvidedOptions?.settings &&
|
|
4945
|
-
modelConfig && {
|
|
4946
|
-
settings: {
|
|
4947
|
-
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
4948
|
-
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
4949
|
-
},
|
|
4950
|
-
}),
|
|
5133
|
+
...(settings && { settings }),
|
|
4951
5134
|
env,
|
|
4952
5135
|
// Override certain fields that must be controlled by ACP
|
|
4953
5136
|
cwd: params.cwd,
|
|
4954
5137
|
includePartialMessages: true,
|
|
4955
5138
|
forwardSubagentText,
|
|
4956
|
-
mcpServers: {
|
|
5139
|
+
mcpServers: {
|
|
5140
|
+
...(userProvidedOptions?.mcpServers || {}),
|
|
5141
|
+
...mcpServers,
|
|
5142
|
+
...(fileChangeAuditSupport
|
|
5143
|
+
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
|
5144
|
+
: {}),
|
|
5145
|
+
},
|
|
4957
5146
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4958
5147
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
4959
5148
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
4960
|
-
permissionMode,
|
|
5149
|
+
permissionMode: initialPermissionMode,
|
|
4961
5150
|
canUseTool: this.canUseTool(sessionId),
|
|
4962
5151
|
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
4963
5152
|
// when the client advertised support, so non-supporting clients keep the
|
|
@@ -4987,40 +5176,42 @@ export class ClaudeAcpAgent {
|
|
|
4987
5176
|
tools,
|
|
4988
5177
|
hooks: {
|
|
4989
5178
|
...userProvidedOptions?.hooks,
|
|
5179
|
+
...(fileChangeAuditSupport
|
|
5180
|
+
? {
|
|
5181
|
+
PreToolUse: [
|
|
5182
|
+
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
|
5183
|
+
{ hooks: [fileChangeAuditSupport.preToolUseHook] },
|
|
5184
|
+
],
|
|
5185
|
+
}
|
|
5186
|
+
: {}),
|
|
4990
5187
|
PostToolUse: [
|
|
4991
5188
|
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
4992
5189
|
{
|
|
4993
5190
|
hooks: [
|
|
4994
5191
|
createPostToolUseHook({
|
|
4995
5192
|
onEnterPlanMode: async () => {
|
|
4996
|
-
await this.
|
|
4997
|
-
sessionId,
|
|
4998
|
-
update: {
|
|
4999
|
-
sessionUpdate: "current_mode_update",
|
|
5000
|
-
currentModeId: "plan",
|
|
5001
|
-
},
|
|
5002
|
-
});
|
|
5193
|
+
await this.sessionModes.publishCurrent(sessionId, "plan");
|
|
5003
5194
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
|
5004
5195
|
},
|
|
5005
5196
|
}),
|
|
5006
5197
|
],
|
|
5007
5198
|
},
|
|
5008
5199
|
],
|
|
5200
|
+
...(fileChangeAuditSupport
|
|
5201
|
+
? {
|
|
5202
|
+
Stop: [
|
|
5203
|
+
...(userProvidedOptions?.hooks?.Stop || []),
|
|
5204
|
+
{ hooks: [fileChangeAuditSupport.stopHook] },
|
|
5205
|
+
],
|
|
5206
|
+
}
|
|
5207
|
+
: {}),
|
|
5009
5208
|
TaskCreated: [
|
|
5010
5209
|
...(userProvidedOptions?.hooks?.TaskCreated || []),
|
|
5011
5210
|
{
|
|
5012
5211
|
hooks: [
|
|
5013
5212
|
createTaskHook({
|
|
5014
5213
|
taskState,
|
|
5015
|
-
onChange:
|
|
5016
|
-
await this.client.sessionUpdate({
|
|
5017
|
-
sessionId,
|
|
5018
|
-
update: {
|
|
5019
|
-
sessionUpdate: "plan",
|
|
5020
|
-
entries: taskStateToPlanEntries(taskState),
|
|
5021
|
-
},
|
|
5022
|
-
});
|
|
5023
|
-
},
|
|
5214
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
5024
5215
|
}),
|
|
5025
5216
|
],
|
|
5026
5217
|
},
|
|
@@ -5031,35 +5222,24 @@ export class ClaudeAcpAgent {
|
|
|
5031
5222
|
hooks: [
|
|
5032
5223
|
createTaskHook({
|
|
5033
5224
|
taskState,
|
|
5034
|
-
onChange:
|
|
5035
|
-
await this.client.sessionUpdate({
|
|
5036
|
-
sessionId,
|
|
5037
|
-
update: {
|
|
5038
|
-
sessionUpdate: "plan",
|
|
5039
|
-
entries: taskStateToPlanEntries(taskState),
|
|
5040
|
-
},
|
|
5041
|
-
});
|
|
5042
|
-
},
|
|
5225
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
5043
5226
|
}),
|
|
5044
5227
|
],
|
|
5045
5228
|
},
|
|
5046
5229
|
],
|
|
5047
5230
|
},
|
|
5048
|
-
...creationOpts,
|
|
5231
|
+
...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
|
|
5232
|
+
...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
|
|
5049
5233
|
abortController,
|
|
5050
5234
|
};
|
|
5051
5235
|
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
|
5052
5236
|
// legacy `_meta.additionalRoots` extension for clients that haven't been
|
|
5053
5237
|
// updated yet. Either source is merged with directories supplied via
|
|
5054
5238
|
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
|
|
5055
|
-
|
|
5056
|
-
options.additionalDirectories = [
|
|
5057
|
-
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
5058
|
-
...acpAdditionalDirectories,
|
|
5059
|
-
];
|
|
5239
|
+
options.additionalDirectories = additionalDirectories;
|
|
5060
5240
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
5061
5241
|
// Set our own session id if not resuming an existing session.
|
|
5062
|
-
options.sessionId = sessionId;
|
|
5242
|
+
options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
|
|
5063
5243
|
}
|
|
5064
5244
|
// Handle abort controller from meta options
|
|
5065
5245
|
if (abortController?.signal.aborted) {
|
|
@@ -5114,8 +5294,8 @@ export class ClaudeAcpAgent {
|
|
|
5114
5294
|
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
|
|
5115
5295
|
: hideDeprecatedModels(initializationResult.models, this.logger);
|
|
5116
5296
|
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
5117
|
-
//
|
|
5118
|
-
//
|
|
5297
|
+
// Resolve the current model's capabilities separately from the stable
|
|
5298
|
+
// permission-mode catalog advertised to ACP clients.
|
|
5119
5299
|
// Looked up in the UNfiltered catalog: a session honoring a persisted
|
|
5120
5300
|
// deprecated preference must keep that model's real capabilities (R4.3).
|
|
5121
5301
|
// A resumed session can also be running a model outside the
|
|
@@ -5156,38 +5336,12 @@ export class ClaudeAcpAgent {
|
|
|
5156
5336
|
},
|
|
5157
5337
|
]
|
|
5158
5338
|
: catalogModels;
|
|
5159
|
-
const
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
// (matches what was passed into `options.permissionMode` above) and use
|
|
5166
|
-
// `effectiveMode` for the post-clamp value the session actually runs in.
|
|
5167
|
-
let effectiveMode = permissionMode;
|
|
5168
|
-
if (!availableModes.some((m) => m.id === effectiveMode)) {
|
|
5169
|
-
if (effectiveMode === "auto") {
|
|
5170
|
-
this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
|
|
5171
|
-
`"${models.currentModelId}"; falling back to "default".`);
|
|
5172
|
-
}
|
|
5173
|
-
else {
|
|
5174
|
-
this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
|
5175
|
-
`this session; falling back to "default".`);
|
|
5176
|
-
}
|
|
5177
|
-
effectiveMode = "default";
|
|
5178
|
-
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
|
|
5179
|
-
// try/catch since failing here would abort session creation entirely.
|
|
5180
|
-
try {
|
|
5181
|
-
await q.setPermissionMode("default");
|
|
5182
|
-
}
|
|
5183
|
-
catch (err) {
|
|
5184
|
-
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
|
|
5185
|
-
}
|
|
5186
|
-
}
|
|
5187
|
-
const modes = {
|
|
5188
|
-
currentModeId: effectiveMode,
|
|
5189
|
-
availableModes,
|
|
5190
|
-
};
|
|
5339
|
+
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5340
|
+
query: q,
|
|
5341
|
+
requestedMode: initialPermissionMode,
|
|
5342
|
+
currentModelInfo,
|
|
5343
|
+
currentModelId: models.currentModelId,
|
|
5344
|
+
});
|
|
5191
5345
|
const agents = await discoverCustomAgents(q);
|
|
5192
5346
|
// Only adopt the requested agent as the selected value if it's one we
|
|
5193
5347
|
// actually surface in the picker. A built-in (filtered out above) or
|
|
@@ -5214,6 +5368,7 @@ export class ClaudeAcpAgent {
|
|
|
5214
5368
|
const fastMode = {
|
|
5215
5369
|
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
5216
5370
|
enabled: fastModeEnabled,
|
|
5371
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
5217
5372
|
disabledReason: fastModeDisabledReason,
|
|
5218
5373
|
};
|
|
5219
5374
|
const configOptions = buildConfigOptions(modes, models,
|
|
@@ -5279,7 +5434,9 @@ export class ClaudeAcpAgent {
|
|
|
5279
5434
|
// can rebuild an equivalent query without re-running this assembly.
|
|
5280
5435
|
queryOptions: options,
|
|
5281
5436
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
5437
|
+
creationParams: params,
|
|
5282
5438
|
settingsManager,
|
|
5439
|
+
titles: new SessionTitles(this, sessionId),
|
|
5283
5440
|
accumulatedUsage: {
|
|
5284
5441
|
inputTokens: 0,
|
|
5285
5442
|
outputTokens: 0,
|
|
@@ -5293,6 +5450,8 @@ export class ClaudeAcpAgent {
|
|
|
5293
5450
|
// capability lookups and `resolveModelPreference` (refusal fallback),
|
|
5294
5451
|
// which must keep seeing deprecated rows (R4.3, visibility-only filter).
|
|
5295
5452
|
modelInfos,
|
|
5453
|
+
autoModeFallbackWarningShown: false,
|
|
5454
|
+
autoModeFallbackWarningPending,
|
|
5296
5455
|
configOptions,
|
|
5297
5456
|
agents,
|
|
5298
5457
|
currentAgent,
|
|
@@ -5311,6 +5470,9 @@ export class ClaudeAcpAgent {
|
|
|
5311
5470
|
emittedAssistantText: false,
|
|
5312
5471
|
owedTrailingIdles: 0,
|
|
5313
5472
|
messageIdToUuid: new Map(),
|
|
5473
|
+
sessionFailureState: createSessionFailureState(),
|
|
5474
|
+
fileChangeReportRequestIds: new Set(),
|
|
5475
|
+
fileChangeAuditSupport,
|
|
5314
5476
|
};
|
|
5315
5477
|
return {
|
|
5316
5478
|
sessionId,
|
|
@@ -5318,6 +5480,41 @@ export class ClaudeAcpAgent {
|
|
|
5318
5480
|
configOptions,
|
|
5319
5481
|
};
|
|
5320
5482
|
}
|
|
5483
|
+
/**
|
|
5484
|
+
* Provider routing is baked into the environment of each SDK Query. Wait for
|
|
5485
|
+
* all submitted turns to settle, close every query, then resume each Claude
|
|
5486
|
+
* session with the same ID so subsequent turns inherit the new environment.
|
|
5487
|
+
*/
|
|
5488
|
+
async enqueueProviderUpdate(config) {
|
|
5489
|
+
const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
|
|
5490
|
+
const update = previous.then(async () => {
|
|
5491
|
+
const sessions = Object.entries(this.sessions);
|
|
5492
|
+
const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
|
|
5493
|
+
if (activeTurns.length > 0) {
|
|
5494
|
+
this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
|
|
5495
|
+
await Promise.all(activeTurns);
|
|
5496
|
+
}
|
|
5497
|
+
this.providerConfig = config;
|
|
5498
|
+
for (const [sessionId, session] of sessions) {
|
|
5499
|
+
if (this.sessions[sessionId] !== session || !session.creationParams) {
|
|
5500
|
+
continue;
|
|
5501
|
+
}
|
|
5502
|
+
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
|
5503
|
+
this.closeQueryStream(session);
|
|
5504
|
+
delete this.sessions[sessionId];
|
|
5505
|
+
await this.createSession(session.creationParams, { resume: sessionId });
|
|
5506
|
+
}
|
|
5507
|
+
});
|
|
5508
|
+
this.providerUpdate = update;
|
|
5509
|
+
try {
|
|
5510
|
+
await update;
|
|
5511
|
+
}
|
|
5512
|
+
finally {
|
|
5513
|
+
if (this.providerUpdate === update) {
|
|
5514
|
+
this.providerUpdate = null;
|
|
5515
|
+
}
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5321
5518
|
}
|
|
5322
5519
|
function shouldEmitRawMessage(config, message) {
|
|
5323
5520
|
if (config === true)
|
|
@@ -5405,13 +5602,27 @@ function createEnvForProvider(config) {
|
|
|
5405
5602
|
if (!config) {
|
|
5406
5603
|
return {};
|
|
5407
5604
|
}
|
|
5605
|
+
const resetRouting = {
|
|
5606
|
+
ANTHROPIC_BASE_URL: "",
|
|
5607
|
+
ANTHROPIC_BEDROCK_BASE_URL: "",
|
|
5608
|
+
ANTHROPIC_VERTEX_BASE_URL: "",
|
|
5609
|
+
CLAUDE_CODE_USE_BEDROCK: "0",
|
|
5610
|
+
CLAUDE_CODE_USE_VERTEX: "0",
|
|
5611
|
+
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
|
5612
|
+
CLOUD_ML_REGION: "",
|
|
5613
|
+
AWS_REGION: "",
|
|
5614
|
+
ANTHROPIC_API_KEY: "",
|
|
5615
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
5616
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
5617
|
+
};
|
|
5408
5618
|
const customHeaders = Object.entries(config.headers)
|
|
5409
5619
|
.map(([key, value]) => `${key}: ${value}`)
|
|
5410
5620
|
.join("\n");
|
|
5411
5621
|
if (config.apiType === "bedrock") {
|
|
5412
5622
|
return {
|
|
5623
|
+
...resetRouting,
|
|
5413
5624
|
CLAUDE_CODE_USE_BEDROCK: "1",
|
|
5414
|
-
AWS_BEARER_TOKEN_BEDROCK: "
|
|
5625
|
+
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
|
|
5415
5626
|
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
|
5416
5627
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5417
5628
|
};
|
|
@@ -5420,6 +5631,7 @@ function createEnvForProvider(config) {
|
|
|
5420
5631
|
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
|
5421
5632
|
// validation; fall back to empty strings defensively.
|
|
5422
5633
|
return {
|
|
5634
|
+
...resetRouting,
|
|
5423
5635
|
CLAUDE_CODE_USE_VERTEX: "1",
|
|
5424
5636
|
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
|
|
5425
5637
|
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
|
@@ -5428,9 +5640,10 @@ function createEnvForProvider(config) {
|
|
|
5428
5640
|
};
|
|
5429
5641
|
}
|
|
5430
5642
|
return {
|
|
5643
|
+
...resetRouting,
|
|
5431
5644
|
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
5432
5645
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5433
|
-
ANTHROPIC_AUTH_TOKEN: "
|
|
5646
|
+
ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
|
|
5434
5647
|
};
|
|
5435
5648
|
}
|
|
5436
5649
|
/**
|
|
@@ -5449,50 +5662,6 @@ function isValidBaseUrl(baseUrl) {
|
|
|
5449
5662
|
}
|
|
5450
5663
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
5451
5664
|
}
|
|
5452
|
-
/**
|
|
5453
|
-
* Build the list of permission modes the agent will advertise for the given
|
|
5454
|
-
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
|
|
5455
|
-
* the SDK's model-level availability signal. `undefined`/`false` both exclude
|
|
5456
|
-
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
|
|
5457
|
-
*/
|
|
5458
|
-
function buildAvailableModes(modelInfo) {
|
|
5459
|
-
const modes = [];
|
|
5460
|
-
// Only advertise "auto" when the SDK reports the model supports it.
|
|
5461
|
-
if (modelInfo?.supportsAutoMode === true) {
|
|
5462
|
-
modes.push({
|
|
5463
|
-
id: "auto",
|
|
5464
|
-
name: "Auto",
|
|
5465
|
-
description: "Use a model classifier to approve/deny permission prompts",
|
|
5466
|
-
});
|
|
5467
|
-
}
|
|
5468
|
-
modes.push({
|
|
5469
|
-
// Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
|
|
5470
|
-
// the wire id stays "default" ("manual" is only an accepted input alias).
|
|
5471
|
-
id: "default",
|
|
5472
|
-
name: "Manual",
|
|
5473
|
-
description: "Standard behavior, prompts for dangerous operations",
|
|
5474
|
-
}, {
|
|
5475
|
-
id: "acceptEdits",
|
|
5476
|
-
name: "Accept Edits",
|
|
5477
|
-
description: "Auto-accept file edit operations",
|
|
5478
|
-
}, {
|
|
5479
|
-
id: "plan",
|
|
5480
|
-
name: "Plan Mode",
|
|
5481
|
-
description: "Planning mode, no actual tool execution",
|
|
5482
|
-
}, {
|
|
5483
|
-
id: "dontAsk",
|
|
5484
|
-
name: "Don't Ask",
|
|
5485
|
-
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
5486
|
-
});
|
|
5487
|
-
if (ALLOW_BYPASS) {
|
|
5488
|
-
modes.push({
|
|
5489
|
-
id: "bypassPermissions",
|
|
5490
|
-
name: "Bypass Permissions",
|
|
5491
|
-
description: "Bypass all permission checks",
|
|
5492
|
-
});
|
|
5493
|
-
}
|
|
5494
|
-
return modes;
|
|
5495
|
-
}
|
|
5496
5665
|
// Translate a UI effort value into the flag-layer payload. The SDK
|
|
5497
5666
|
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
|
5498
5667
|
// and only clears a key when an explicit `null` is sent — see
|
|
@@ -5514,7 +5683,6 @@ function toSdkEffortLevel(value) {
|
|
|
5514
5683
|
export const BUILTIN_AGENT_NAMES = new Set([
|
|
5515
5684
|
"claude",
|
|
5516
5685
|
"general-purpose",
|
|
5517
|
-
"claude-code-guide",
|
|
5518
5686
|
"Explore",
|
|
5519
5687
|
"Plan",
|
|
5520
5688
|
"statusline-setup",
|
|
@@ -5524,7 +5692,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
|
|
|
5524
5692
|
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
5525
5693
|
// (two options sharing the value, selection silently routing to `null`), so we
|
|
5526
5694
|
// exclude that name from discovery.
|
|
5527
|
-
export const DEFAULT_AGENT_ID = "default";
|
|
5528
5695
|
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
5529
5696
|
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
5530
5697
|
* list if discovery fails so a flaky control request never blocks session
|
|
@@ -5542,13 +5709,12 @@ export async function discoverCustomAgents(q) {
|
|
|
5542
5709
|
* Centralized so the option declarations in `buildConfigOptions` and the
|
|
5543
5710
|
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
|
5544
5711
|
* same identifiers and can't drift apart. */
|
|
5545
|
-
export
|
|
5712
|
+
export { MODE_CONFIG_ID };
|
|
5546
5713
|
export const MODEL_CONFIG_ID = "model";
|
|
5547
|
-
export const EFFORT_CONFIG_ID = "effort";
|
|
5548
5714
|
export const AGENT_CONFIG_ID = "agent";
|
|
5549
5715
|
export const FAST_MODE_CONFIG_ID = "fast";
|
|
5550
|
-
/** Select values
|
|
5551
|
-
* (see {@link createFastModeConfigOption}). */
|
|
5716
|
+
/** Select-fallback values used when the client has not opted into boolean
|
|
5717
|
+
* config options (see {@link createFastModeConfigOption}). */
|
|
5552
5718
|
export const FAST_MODE_ON = "on";
|
|
5553
5719
|
export const FAST_MODE_OFF = "off";
|
|
5554
5720
|
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
|
@@ -5585,33 +5751,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
|
|
|
5585
5751
|
export function normalizeFastModeDisabledReason(reason) {
|
|
5586
5752
|
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
|
5587
5753
|
}
|
|
5588
|
-
/**
|
|
5589
|
-
*
|
|
5590
|
-
*
|
|
5591
|
-
*
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
* `
|
|
5597
|
-
*
|
|
5598
|
-
*
|
|
5599
|
-
* silently refuses to stay on. Ignored while enabled: a reason reported
|
|
5600
|
-
* alongside an `on`/`cooldown` state isn't blocking anything right now.
|
|
5754
|
+
/** Whether the Client advertised support for boolean session config options
|
|
5755
|
+
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
|
|
5756
|
+
* config options to Clients that opt in; otherwise we fall back to a `select`.
|
|
5757
|
+
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
|
|
5758
|
+
export function clientSupportsBooleanConfigOptions(clientCapabilities) {
|
|
5759
|
+
return clientCapabilities?.session?.configOptions?.boolean != null;
|
|
5760
|
+
}
|
|
5761
|
+
/** Build the Fast mode config option. When the Client supports boolean config
|
|
5762
|
+
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
|
|
5763
|
+
* a two-value `select` ("on"/"off") so older Clients still get a usable
|
|
5764
|
+
* control.
|
|
5601
5765
|
*
|
|
5602
|
-
*
|
|
5603
|
-
*
|
|
5604
|
-
*
|
|
5605
|
-
*
|
|
5606
|
-
|
|
5766
|
+
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
|
|
5767
|
+
* description while the toggle reads off, so a user whose account or provider
|
|
5768
|
+
* can't serve Fast mode sees why instead of a switch that silently refuses to
|
|
5769
|
+
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
|
|
5770
|
+
* state isn't blocking anything right now. */
|
|
5771
|
+
export function createFastModeConfigOption(enabled, useBooleanOption, disabledReason) {
|
|
5607
5772
|
const explanation = enabled
|
|
5608
5773
|
? undefined
|
|
5609
5774
|
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
|
5610
|
-
|
|
5775
|
+
const base = {
|
|
5611
5776
|
id: FAST_MODE_CONFIG_ID,
|
|
5612
5777
|
name: "Fast mode",
|
|
5613
5778
|
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
|
|
5614
5779
|
category: "model_config",
|
|
5780
|
+
};
|
|
5781
|
+
if (useBooleanOption) {
|
|
5782
|
+
return { ...base, type: "boolean", currentValue: enabled };
|
|
5783
|
+
}
|
|
5784
|
+
return {
|
|
5785
|
+
...base,
|
|
5615
5786
|
type: "select",
|
|
5616
5787
|
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
|
5617
5788
|
options: [
|
|
@@ -5621,8 +5792,8 @@ export function createFastModeConfigOption(enabled, disabledReason) {
|
|
|
5621
5792
|
};
|
|
5622
5793
|
}
|
|
5623
5794
|
/** Resolve the requested Fast mode value from a `session/set_config_option`
|
|
5624
|
-
* request. Accepts
|
|
5625
|
-
*
|
|
5795
|
+
* request. Accepts a native boolean (boolean-capable Clients) or the
|
|
5796
|
+
* "on"/"off" select-fallback strings. */
|
|
5626
5797
|
export function resolveFastModeEnabled(params) {
|
|
5627
5798
|
const value = params.value;
|
|
5628
5799
|
if (typeof value === "boolean") {
|
|
@@ -5644,19 +5815,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
5644
5815
|
* callers/tests) omits the row. */
|
|
5645
5816
|
thinkingEnabled) {
|
|
5646
5817
|
const options = [
|
|
5647
|
-
|
|
5648
|
-
id: MODE_CONFIG_ID,
|
|
5649
|
-
name: "Mode",
|
|
5650
|
-
description: "Session permission mode",
|
|
5651
|
-
category: "mode",
|
|
5652
|
-
type: "select",
|
|
5653
|
-
currentValue: modes.currentModeId,
|
|
5654
|
-
options: modes.availableModes.map((m) => ({
|
|
5655
|
-
value: m.id,
|
|
5656
|
-
name: m.name,
|
|
5657
|
-
description: m.description,
|
|
5658
|
-
})),
|
|
5659
|
-
},
|
|
5818
|
+
SessionModeManager.configOption(modes),
|
|
5660
5819
|
{
|
|
5661
5820
|
id: MODEL_CONFIG_ID,
|
|
5662
5821
|
name: "Model",
|
|
@@ -5664,11 +5823,21 @@ thinkingEnabled) {
|
|
|
5664
5823
|
category: "model",
|
|
5665
5824
|
type: "select",
|
|
5666
5825
|
currentValue: models.currentModelId,
|
|
5667
|
-
options: models.availableModels.map((m) =>
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5826
|
+
options: models.availableModels.map((m) => {
|
|
5827
|
+
if (m.modelId === "default") {
|
|
5828
|
+
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
|
|
5829
|
+
const resolvedModel = defaultInfo?.resolvedModel;
|
|
5830
|
+
if (resolvedModel) {
|
|
5831
|
+
const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
|
|
5832
|
+
return {
|
|
5833
|
+
value: m.modelId,
|
|
5834
|
+
name: m.name,
|
|
5835
|
+
description: namedMatch?.displayName ?? resolvedModel,
|
|
5836
|
+
};
|
|
5837
|
+
}
|
|
5838
|
+
}
|
|
5839
|
+
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
|
|
5840
|
+
}),
|
|
5672
5841
|
},
|
|
5673
5842
|
];
|
|
5674
5843
|
// Add effort level option based on the currently selected model
|
|
@@ -5700,10 +5869,10 @@ thinkingEnabled) {
|
|
|
5700
5869
|
});
|
|
5701
5870
|
}
|
|
5702
5871
|
// Surface the Fast mode toggle only when the current model supports it. The
|
|
5703
|
-
// option
|
|
5704
|
-
//
|
|
5872
|
+
// option renders as a native boolean toggle for Clients that opted in, and a
|
|
5873
|
+
// two-value select otherwise.
|
|
5705
5874
|
if (fastMode?.supported) {
|
|
5706
|
-
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
|
|
5875
|
+
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
|
|
5707
5876
|
}
|
|
5708
5877
|
// Surface the Thinking toggle whenever the caller supplies its display
|
|
5709
5878
|
// state. Unlike Fast mode it is model-independent — no `supported` gate —
|
|
@@ -6167,7 +6336,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
|
|
|
6167
6336
|
resumedContextWindow,
|
|
6168
6337
|
};
|
|
6169
6338
|
}
|
|
6170
|
-
function getAvailableSlashCommands(commands
|
|
6339
|
+
function getAvailableSlashCommands(commands,
|
|
6340
|
+
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
|
6341
|
+
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
|
6342
|
+
// the static list. Raw CLI names, matched before the MCP rename.
|
|
6343
|
+
terminalCommands) {
|
|
6171
6344
|
const UNSUPPORTED_COMMANDS = [
|
|
6172
6345
|
"clear",
|
|
6173
6346
|
"cost",
|
|
@@ -6179,6 +6352,7 @@ function getAvailableSlashCommands(commands) {
|
|
|
6179
6352
|
"todos",
|
|
6180
6353
|
];
|
|
6181
6354
|
const advertised = commands
|
|
6355
|
+
.filter((command) => !terminalCommands?.includes(command.name))
|
|
6182
6356
|
.map((command) => {
|
|
6183
6357
|
const input = command.argumentHint
|
|
6184
6358
|
? {
|
|
@@ -6362,13 +6536,13 @@ function isTaskTool(toolName) {
|
|
|
6362
6536
|
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
|
6363
6537
|
* resolved explicitly at tool_result time. */
|
|
6364
6538
|
function shouldEmitToolCall(toolName) {
|
|
6365
|
-
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
6539
|
+
return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
|
|
6366
6540
|
}
|
|
6367
6541
|
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
|
|
6368
6542
|
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
6369
6543
|
* command preview, while still giving clients access to Claude's concise
|
|
6370
6544
|
* human-readable title. */
|
|
6371
|
-
function claudeCodeMetaFromToolUse(toolUse) {
|
|
6545
|
+
function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
6372
6546
|
const description = toolUse.name === "Bash" &&
|
|
6373
6547
|
toolUse.input !== null &&
|
|
6374
6548
|
typeof toolUse.input === "object" &&
|
|
@@ -6376,11 +6550,53 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
6376
6550
|
typeof toolUse.input.description === "string"
|
|
6377
6551
|
? toolUse.input.description
|
|
6378
6552
|
: undefined;
|
|
6553
|
+
const skillName = toolUse.name === "Skill"
|
|
6554
|
+
? toolUse.input?.skill
|
|
6555
|
+
: undefined;
|
|
6556
|
+
const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
|
|
6379
6557
|
return {
|
|
6380
6558
|
toolName: toolUse.name,
|
|
6381
6559
|
...(description ? { title: description } : {}),
|
|
6382
6560
|
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
|
|
6561
|
+
...(skillName ? { skill: skillName } : {}),
|
|
6562
|
+
...(skillPath ? { skillPath } : {}),
|
|
6563
|
+
};
|
|
6564
|
+
}
|
|
6565
|
+
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6566
|
+
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6567
|
+
/**
|
|
6568
|
+
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
|
6569
|
+
*
|
|
6570
|
+
* The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
|
|
6571
|
+
* actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
|
|
6572
|
+
* for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
|
|
6573
|
+
* directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
|
|
6574
|
+
* on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
|
|
6575
|
+
*/
|
|
6576
|
+
function resolveSkillPath(skillName, cwd) {
|
|
6577
|
+
if (!cwd) {
|
|
6578
|
+
return undefined;
|
|
6579
|
+
}
|
|
6580
|
+
const colon = skillName.indexOf(":");
|
|
6581
|
+
const scope = colon < 0 ? undefined : skillName.slice(0, colon);
|
|
6582
|
+
const name = colon < 0 ? skillName : skillName.slice(colon + 1);
|
|
6583
|
+
if (!name) {
|
|
6584
|
+
return undefined;
|
|
6585
|
+
}
|
|
6586
|
+
const candidates = [];
|
|
6587
|
+
const addCandidates = (base) => {
|
|
6588
|
+
for (const container of SKILL_CONTAINER_DIRS) {
|
|
6589
|
+
candidates.push(path.join(base, container, name, "SKILL.md"));
|
|
6590
|
+
}
|
|
6383
6591
|
};
|
|
6592
|
+
if (scope) {
|
|
6593
|
+
// A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
|
|
6594
|
+
addCandidates(path.join(cwd, scope));
|
|
6595
|
+
candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
|
|
6596
|
+
}
|
|
6597
|
+
addCandidates(cwd);
|
|
6598
|
+
addCandidates(os.homedir());
|
|
6599
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
6384
6600
|
}
|
|
6385
6601
|
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
6386
6602
|
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
@@ -6392,7 +6608,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
6392
6608
|
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
6393
6609
|
if (refine) {
|
|
6394
6610
|
return {
|
|
6395
|
-
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
|
|
6611
|
+
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
|
|
6396
6612
|
toolCallId: toolUse.id,
|
|
6397
6613
|
sessionUpdate: "tool_call_update",
|
|
6398
6614
|
rawInput,
|
|
@@ -6401,7 +6617,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
6401
6617
|
}
|
|
6402
6618
|
return {
|
|
6403
6619
|
_meta: {
|
|
6404
|
-
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
|
6620
|
+
claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
|
|
6405
6621
|
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
6406
6622
|
? { terminal_info: { terminal_id: toolUse.id } }
|
|
6407
6623
|
: {}),
|
|
@@ -6427,7 +6643,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6427
6643
|
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
6428
6644
|
return {
|
|
6429
6645
|
_meta: {
|
|
6430
|
-
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
|
6646
|
+
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
|
|
6431
6647
|
},
|
|
6432
6648
|
toolCallId: toolUse.id,
|
|
6433
6649
|
sessionUpdate: "tool_call_update",
|
|
@@ -6437,33 +6653,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6437
6653
|
...(locations ? { locations } : {}),
|
|
6438
6654
|
};
|
|
6439
6655
|
}
|
|
6440
|
-
/** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
|
|
6441
|
-
* wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
|
|
6442
|
-
* by-tool_use_id lookup. Each entry explains why an is_error tool_result
|
|
6443
|
-
* carries harness prose instead of the tool's own output — "user-rejected",
|
|
6444
|
-
* "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
|
|
6445
|
-
* on the wire ahead of schema updates, so no enum check). Malformed entries
|
|
6446
|
-
* are skipped rather than failing the message. */
|
|
6447
|
-
function parseToolResultMeta(raw) {
|
|
6448
|
-
if (!Array.isArray(raw)) {
|
|
6449
|
-
return undefined;
|
|
6450
|
-
}
|
|
6451
|
-
let byToolUseId;
|
|
6452
|
-
for (const entry of raw) {
|
|
6453
|
-
if (typeof entry !== "object" || entry === null) {
|
|
6454
|
-
continue;
|
|
6455
|
-
}
|
|
6456
|
-
const { id, non_execution_kind, user_feedback } = entry;
|
|
6457
|
-
if (typeof id !== "string" || typeof non_execution_kind !== "string") {
|
|
6458
|
-
continue;
|
|
6459
|
-
}
|
|
6460
|
-
(byToolUseId ??= new Map()).set(id, {
|
|
6461
|
-
nonExecutionKind: non_execution_kind,
|
|
6462
|
-
...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
|
|
6463
|
-
});
|
|
6464
|
-
}
|
|
6465
|
-
return byToolUseId;
|
|
6466
|
-
}
|
|
6467
6656
|
/**
|
|
6468
6657
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
6469
6658
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -6473,7 +6662,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6473
6662
|
const registerHooks = options?.registerHooks !== false;
|
|
6474
6663
|
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
6475
6664
|
if (typeof content === "string") {
|
|
6476
|
-
if (content.length === 0) {
|
|
6665
|
+
if (content.length === 0 || containsFileChangeAuditMarker(content)) {
|
|
6477
6666
|
return [];
|
|
6478
6667
|
}
|
|
6479
6668
|
const update = {
|
|
@@ -6507,6 +6696,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6507
6696
|
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
|
6508
6697
|
// messages need no single-block guard.
|
|
6509
6698
|
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
|
6699
|
+
// A report-phase assistant message may contain a short text preface and the
|
|
6700
|
+
// internal tool call in the same content array. Hide the whole message, not
|
|
6701
|
+
// only the tool block, so it stays absent on session replay as well as live.
|
|
6702
|
+
const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
|
|
6703
|
+
chunk.type === "server_tool_use" ||
|
|
6704
|
+
chunk.type === "mcp_tool_use") &&
|
|
6705
|
+
isFileChangeAuditTool(chunk.name));
|
|
6510
6706
|
const output = [];
|
|
6511
6707
|
// Only handle the first chunk for streaming; extend as needed for batching
|
|
6512
6708
|
for (const chunk of content) {
|
|
@@ -6514,7 +6710,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6514
6710
|
switch (chunk.type) {
|
|
6515
6711
|
case "text":
|
|
6516
6712
|
case "text_delta": {
|
|
6517
|
-
if (chunk.text
|
|
6713
|
+
if (chunk.text &&
|
|
6714
|
+
!containsFileChangeAuditToolUse &&
|
|
6715
|
+
!containsFileChangeAuditMarker(chunk.text)) {
|
|
6518
6716
|
update = {
|
|
6519
6717
|
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6520
6718
|
content: {
|
|
@@ -6526,21 +6724,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6526
6724
|
break;
|
|
6527
6725
|
}
|
|
6528
6726
|
case "image":
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6727
|
+
if (!containsFileChangeAuditToolUse)
|
|
6728
|
+
update = {
|
|
6729
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6730
|
+
content: {
|
|
6731
|
+
type: "image",
|
|
6732
|
+
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
|
6733
|
+
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
|
6734
|
+
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
|
6735
|
+
},
|
|
6736
|
+
};
|
|
6538
6737
|
break;
|
|
6539
6738
|
case "thinking":
|
|
6540
6739
|
case "thinking_delta": {
|
|
6541
6740
|
// Recent models default `thinking.display` to "omitted", which streams
|
|
6542
6741
|
// signature-only thinking blocks whose text is empty.
|
|
6543
|
-
if (chunk.thinking) {
|
|
6742
|
+
if (chunk.thinking && !containsFileChangeAuditToolUse) {
|
|
6544
6743
|
update = {
|
|
6545
6744
|
sessionUpdate: "agent_thought_chunk",
|
|
6546
6745
|
content: {
|
|
@@ -6556,7 +6755,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6556
6755
|
case "mcp_tool_use": {
|
|
6557
6756
|
const alreadyCached = chunk.id in toolUseCache;
|
|
6558
6757
|
toolUseCache[chunk.id] = chunk;
|
|
6559
|
-
if (chunk.name
|
|
6758
|
+
if (isFileChangeAuditTool(chunk.name)) {
|
|
6759
|
+
// Wrapper-owned audit protocol: never surface or register generic
|
|
6760
|
+
// PostToolUse callbacks for the internal tool.
|
|
6761
|
+
}
|
|
6762
|
+
else if (chunk.name === "TodoWrite") {
|
|
6560
6763
|
// @ts-expect-error - sometimes input is empty object or undefined
|
|
6561
6764
|
if (Array.isArray(chunk.input?.todos)) {
|
|
6562
6765
|
update = {
|
|
@@ -6680,6 +6883,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6680
6883
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
6681
6884
|
break;
|
|
6682
6885
|
}
|
|
6886
|
+
if (isFileChangeAuditTool(toolUse.name)) {
|
|
6887
|
+
delete toolUseCache[chunk.tool_use_id];
|
|
6888
|
+
break;
|
|
6889
|
+
}
|
|
6683
6890
|
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
|
6684
6891
|
// suppressed (Task*) tool as a real tool_call so the request referenced
|
|
6685
6892
|
// a tool call the client knows about (see `ensureToolCallEmitted`,
|
|
@@ -6710,20 +6917,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6710
6917
|
}
|
|
6711
6918
|
if (isTaskTool(toolUse.name)) {
|
|
6712
6919
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
6713
|
-
// TaskCreate / TaskUpdate mutate the accumulated task list
|
|
6714
|
-
//
|
|
6715
|
-
//
|
|
6716
|
-
//
|
|
6920
|
+
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
|
6921
|
+
// reconciles it from the SDK's authoritative snapshot, which repairs
|
|
6922
|
+
// resumed or compacted sessions whose creating calls are no longer in
|
|
6923
|
+
// replay history. TaskGet is read-only and remains suppressed. Plan
|
|
6924
|
+
// updates always carry the full accumulated snapshot, mirroring the
|
|
6925
|
+
// legacy TodoWrite behavior.
|
|
6717
6926
|
const isError = "is_error" in chunk && chunk.is_error;
|
|
6927
|
+
let shouldEmitTaskPlan = false;
|
|
6718
6928
|
if (!isError) {
|
|
6719
6929
|
if (toolUse.name === "TaskCreate") {
|
|
6720
|
-
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
|
|
6930
|
+
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
|
|
6931
|
+
shouldEmitTaskPlan = true;
|
|
6721
6932
|
}
|
|
6722
6933
|
else if (toolUse.name === "TaskUpdate") {
|
|
6723
|
-
|
|
6934
|
+
const input = toolUse.input;
|
|
6935
|
+
const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
|
6936
|
+
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
|
6937
|
+
// Older CLI transcripts have no structured output, so retain the
|
|
6938
|
+
// input-based fallback. When an output is available, only apply a
|
|
6939
|
+
// confirmed update for the same task.
|
|
6940
|
+
if (!output || (output.success && output.taskId === input?.taskId)) {
|
|
6941
|
+
applyTaskUpdate(taskState, input);
|
|
6942
|
+
shouldEmitTaskPlan = true;
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
6945
|
+
else if (toolUse.name === "TaskList") {
|
|
6946
|
+
const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
|
6947
|
+
if (output) {
|
|
6948
|
+
applyTaskList(taskState, output);
|
|
6949
|
+
shouldEmitTaskPlan = true;
|
|
6950
|
+
}
|
|
6724
6951
|
}
|
|
6725
6952
|
}
|
|
6726
|
-
if (
|
|
6953
|
+
if (shouldEmitTaskPlan) {
|
|
6727
6954
|
update = {
|
|
6728
6955
|
sessionUpdate: "plan",
|
|
6729
6956
|
entries: taskStateToPlanEntries(taskState),
|
|
@@ -6763,7 +6990,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6763
6990
|
toolCallId: chunk.tool_use_id,
|
|
6764
6991
|
sessionUpdate: "tool_call_update",
|
|
6765
6992
|
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
6766
|
-
|
|
6993
|
+
// terminal_output already carried the exact bytes in the preceding
|
|
6994
|
+
// update. Repeating them as rawOutput wastes bandwidth and lets a
|
|
6995
|
+
// client accidentally render the same output twice.
|
|
6996
|
+
...(toolMeta?.terminal_output
|
|
6997
|
+
? {}
|
|
6998
|
+
: { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
|
|
6767
6999
|
...toolUpdate,
|
|
6768
7000
|
};
|
|
6769
7001
|
}
|
|
@@ -6784,7 +7016,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6784
7016
|
case "compaction":
|
|
6785
7017
|
case "compaction_delta":
|
|
6786
7018
|
case "advisor_tool_result":
|
|
6787
|
-
case "mid_conv_system":
|
|
6788
7019
|
case "fallback":
|
|
6789
7020
|
break;
|
|
6790
7021
|
default:
|
|
@@ -6941,7 +7172,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
|
|
|
6941
7172
|
signal.removeEventListener("abort", onAbort);
|
|
6942
7173
|
}
|
|
6943
7174
|
}
|
|
6944
|
-
export function runAcp() {
|
|
7175
|
+
export function runAcp(logger) {
|
|
6945
7176
|
const input = nodeToWebWritable(process.stdout);
|
|
6946
7177
|
const output = nodeToWebReadable(process.stdin);
|
|
6947
7178
|
const stream = ndJsonStream(input, output);
|
|
@@ -6974,7 +7205,7 @@ export function runAcp() {
|
|
|
6974
7205
|
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
6975
7206
|
.onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
|
|
6976
7207
|
.connect(stream);
|
|
6977
|
-
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
7208
|
+
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
|
6978
7209
|
return { connection, agent };
|
|
6979
7210
|
}
|
|
6980
7211
|
function commonPrefixLength(a, b) {
|
|
@@ -7069,6 +7300,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
|
|
|
7069
7300
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
7070
7301
|
"ANTHROPIC_API_KEY",
|
|
7071
7302
|
"ANTHROPIC_AUTH_TOKEN",
|
|
7303
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
7072
7304
|
];
|
|
7073
7305
|
/** Stable identifier for the LLM backend a session's query is created against,
|
|
7074
7306
|
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|