@lucascouts/claude-agent-acp-plus 0.8.1 → 0.10.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 +132 -103
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1096 -830
- 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 +249 -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 +179 -24
- package/package.json +4 -4
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,
|
|
@@ -73,9 +76,13 @@ const STEER_METHOD = "_session/steering";
|
|
|
73
76
|
* turn — an internal Claude implementation detail, not part of the wire
|
|
74
77
|
* contract. `now` pre-empts the current generation and handles the message
|
|
75
78
|
* immediately (interrupting a single-shot response, or slotting in between a
|
|
76
|
-
* multi-step turn's tool calls)
|
|
77
|
-
*
|
|
78
|
-
|
|
79
|
+
* multi-step turn's tool calls), while `later` waits for a pending
|
|
80
|
+
* permission/elicitation callback to settle instead of cancelling its ACP
|
|
81
|
+
* request and hiding the client's user-input card (IJAI-1191). Maps to
|
|
82
|
+
* `SDKUserMessage.priority`; injected steering uses `now` unless the session is
|
|
83
|
+
* awaiting user input (see `Session.pendingUserInputCount`). */
|
|
84
|
+
const STEER_PRIORITY_NOW = "now";
|
|
85
|
+
const STEER_PRIORITY_LATER = "later";
|
|
79
86
|
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
|
80
87
|
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
|
81
88
|
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
|
@@ -114,11 +121,13 @@ function parseSteerRequest(params) {
|
|
|
114
121
|
* result is the turn's real terminal.
|
|
115
122
|
*
|
|
116
123
|
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
|
117
|
-
* lane
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* result
|
|
121
|
-
*
|
|
124
|
+
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
|
125
|
+
* attribute this" marker, which gets the same safe default. Misrouting a
|
|
126
|
+
* USER result into the autonomous lane hangs the prompt un-detectably
|
|
127
|
+
* (the result is skipped, its trailing idle absorbed as owed, so the
|
|
128
|
+
* #825 detector can't fire); misrouting an autonomous result into the
|
|
129
|
+
* user lane is the bounded misattribution class this set exists to
|
|
130
|
+
* reduce. */
|
|
122
131
|
const AUTONOMOUS_RESULT_ORIGINS = new Set([
|
|
123
132
|
"task-notification",
|
|
124
133
|
"peer",
|
|
@@ -157,17 +166,9 @@ function computeSessionFingerprint(params) {
|
|
|
157
166
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
158
167
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
159
168
|
}
|
|
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
169
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
170
|
+
const PROVIDER_ID = "main";
|
|
171
|
+
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
171
172
|
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
|
172
173
|
function supportsSubagentTranscript(capabilities) {
|
|
173
174
|
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
@@ -296,9 +297,6 @@ function shouldHideClaudeAuth() {
|
|
|
296
297
|
* query stream has already ended (ran to `done` or died). The stream is not
|
|
297
298
|
* revivable, so the only recovery is a fresh session. */
|
|
298
299
|
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
300
|
// Slash commands that the SDK handles locally without replaying the user
|
|
303
301
|
// message and without invoking the model.
|
|
304
302
|
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
@@ -408,189 +406,6 @@ export function isSyntheticLoginMessage(apiMessage) {
|
|
|
408
406
|
typeof block.text === "string" &&
|
|
409
407
|
block.text.includes("Please run /login"));
|
|
410
408
|
}
|
|
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
409
|
/**
|
|
595
410
|
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
|
596
411
|
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
|
@@ -628,16 +443,38 @@ class ClientConnection {
|
|
|
628
443
|
return this.ctx.notify(method, params);
|
|
629
444
|
}
|
|
630
445
|
}
|
|
446
|
+
function raceWithAbort(operation, signal) {
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
449
|
+
const onAbort = () => {
|
|
450
|
+
cleanup();
|
|
451
|
+
reject(new Error("Tool use aborted"));
|
|
452
|
+
};
|
|
453
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
454
|
+
if (signal.aborted) {
|
|
455
|
+
onAbort();
|
|
456
|
+
}
|
|
457
|
+
void operation.then((value) => {
|
|
458
|
+
cleanup();
|
|
459
|
+
resolve(value);
|
|
460
|
+
}, (error) => {
|
|
461
|
+
cleanup();
|
|
462
|
+
reject(error);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
}
|
|
631
466
|
export class ClaudeAcpAgent {
|
|
632
467
|
sessions;
|
|
633
468
|
client;
|
|
634
469
|
clientCapabilities;
|
|
635
470
|
logger;
|
|
471
|
+
sessionModes;
|
|
636
472
|
gatewayAuthRequest;
|
|
637
|
-
/**
|
|
638
|
-
* never persisted to disk (see the Configurable LLM Providers RFD). When
|
|
639
|
-
* set, it takes precedence over {@link gatewayAuthRequest}. */
|
|
473
|
+
/** Set while ACP overrides the agent's native provider configuration. */
|
|
640
474
|
providerConfig;
|
|
475
|
+
/** Serializes provider changes while every open query is recreated between turns. */
|
|
476
|
+
providerUpdate = null;
|
|
477
|
+
exitPlan;
|
|
641
478
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
642
479
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
643
480
|
* tests can shrink it. */
|
|
@@ -646,6 +483,48 @@ export class ClaudeAcpAgent {
|
|
|
646
483
|
this.sessions = {};
|
|
647
484
|
this.client = client;
|
|
648
485
|
this.logger = logger ?? console;
|
|
486
|
+
this.exitPlan = new ExitPlanCoordinator({
|
|
487
|
+
currentSession: (id) => this.sessions[id],
|
|
488
|
+
closeQueryStream: (session) => this.closeQueryStream(session),
|
|
489
|
+
restartSession: async (params, options) => {
|
|
490
|
+
await this.createSession(params, options);
|
|
491
|
+
const session = this.sessions[options.publicSessionId];
|
|
492
|
+
if (!session)
|
|
493
|
+
throw new Error("Fresh Claude context was not created");
|
|
494
|
+
return session;
|
|
495
|
+
},
|
|
496
|
+
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
|
|
497
|
+
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
498
|
+
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
|
|
499
|
+
logError: (message, error) => this.logger.error(message, error),
|
|
500
|
+
destroyReplacement: (id, session) => {
|
|
501
|
+
disarmForceCancel(session);
|
|
502
|
+
session.cancelController?.abort();
|
|
503
|
+
this.closeQueryStream(session);
|
|
504
|
+
session.abortController.abort();
|
|
505
|
+
if (this.sessions[id] === session)
|
|
506
|
+
delete this.sessions[id];
|
|
507
|
+
},
|
|
508
|
+
settleCancelledTurn: (original, session, turn) => {
|
|
509
|
+
disarmForceCancel(session);
|
|
510
|
+
this.finishFileChangeAudit(session, turn, "cancelled");
|
|
511
|
+
turn.settled = true;
|
|
512
|
+
turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
|
|
513
|
+
},
|
|
514
|
+
settleFailedTurn: (session, turn, error) => {
|
|
515
|
+
disarmForceCancel(session);
|
|
516
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
517
|
+
turn.settled = true;
|
|
518
|
+
turn.reject(error);
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
this.sessionModes = new SessionModeManager({
|
|
522
|
+
getSession: (sessionId) => this.sessions[sessionId],
|
|
523
|
+
sessionEndedMessage: SESSION_ENDED_MESSAGE,
|
|
524
|
+
updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
|
|
525
|
+
sessionUpdate: (params) => this.client.sessionUpdate(params),
|
|
526
|
+
logError: (...args) => this.logger.error(...args),
|
|
527
|
+
});
|
|
649
528
|
}
|
|
650
529
|
async initialize(request) {
|
|
651
530
|
this.clientCapabilities = request.clientCapabilities;
|
|
@@ -790,6 +669,7 @@ export class ClaudeAcpAgent {
|
|
|
790
669
|
// steering extension contract: advertises the `_session/steering` request
|
|
791
670
|
// so clients know they may inject a follow-up into a running turn.
|
|
792
671
|
_meta: {
|
|
672
|
+
...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
|
|
793
673
|
steering: {
|
|
794
674
|
supported: true,
|
|
795
675
|
},
|
|
@@ -802,6 +682,8 @@ export class ClaudeAcpAgent {
|
|
|
802
682
|
};
|
|
803
683
|
}
|
|
804
684
|
async newSession(params) {
|
|
685
|
+
if (this.providerUpdate)
|
|
686
|
+
await this.providerUpdate;
|
|
805
687
|
const response = await this.createSession(params, {
|
|
806
688
|
// Revisit these meta values once we support resume
|
|
807
689
|
resume: params._meta?.claudeCode?.options?.resume,
|
|
@@ -813,6 +695,8 @@ export class ClaudeAcpAgent {
|
|
|
813
695
|
return response;
|
|
814
696
|
}
|
|
815
697
|
async unstable_forkSession(params) {
|
|
698
|
+
if (this.providerUpdate)
|
|
699
|
+
await this.providerUpdate;
|
|
816
700
|
const response = await this.createSession({
|
|
817
701
|
cwd: params.cwd,
|
|
818
702
|
mcpServers: params.mcpServers ?? [],
|
|
@@ -829,6 +713,8 @@ export class ClaudeAcpAgent {
|
|
|
829
713
|
return response;
|
|
830
714
|
}
|
|
831
715
|
async resumeSession(params) {
|
|
716
|
+
if (this.providerUpdate)
|
|
717
|
+
await this.providerUpdate;
|
|
832
718
|
const result = await this.getOrCreateSession(params);
|
|
833
719
|
// Needs to happen after we return the session
|
|
834
720
|
setTimeout(() => {
|
|
@@ -837,6 +723,8 @@ export class ClaudeAcpAgent {
|
|
|
837
723
|
return result;
|
|
838
724
|
}
|
|
839
725
|
async loadSession(params) {
|
|
726
|
+
if (this.providerUpdate)
|
|
727
|
+
await this.providerUpdate;
|
|
840
728
|
const result = await this.getOrCreateSession(params);
|
|
841
729
|
await this.replaySessionHistory(params.sessionId);
|
|
842
730
|
// Send available commands after replay so it doesn't interleave with history
|
|
@@ -862,40 +750,6 @@ export class ClaudeAcpAgent {
|
|
|
862
750
|
sessions,
|
|
863
751
|
};
|
|
864
752
|
}
|
|
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
753
|
async authenticate(_params) {
|
|
900
754
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
901
755
|
this.gatewayAuthRequest = _params;
|
|
@@ -903,21 +757,14 @@ export class ClaudeAcpAgent {
|
|
|
903
757
|
}
|
|
904
758
|
throw new Error("Method not implemented.");
|
|
905
759
|
}
|
|
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
760
|
async unstable_listProviders(_params) {
|
|
915
|
-
const config = this.
|
|
761
|
+
const config = this.providerConfig ?? this.defaultProviderConfig();
|
|
762
|
+
this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
|
|
916
763
|
const provider = {
|
|
917
764
|
providerId: PROVIDER_ID,
|
|
918
765
|
supported: SUPPORTED_PROTOCOLS,
|
|
919
766
|
required: false,
|
|
920
|
-
current:
|
|
767
|
+
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
|
921
768
|
};
|
|
922
769
|
return { providers: [provider] };
|
|
923
770
|
}
|
|
@@ -955,34 +802,49 @@ export class ClaudeAcpAgent {
|
|
|
955
802
|
}
|
|
956
803
|
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
|
957
804
|
}
|
|
958
|
-
this.
|
|
805
|
+
this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
|
|
806
|
+
await this.enqueueProviderUpdate(config);
|
|
959
807
|
return {};
|
|
960
808
|
}
|
|
961
809
|
/**
|
|
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.
|
|
810
|
+
* `providers/disable` ends ACP ownership of the single mutually exclusive
|
|
811
|
+
* backend slot and restores the agent's native routing state.
|
|
967
812
|
*/
|
|
968
813
|
async unstable_disableProvider(params) {
|
|
969
814
|
if (params.providerId === PROVIDER_ID) {
|
|
970
|
-
this.
|
|
971
|
-
this.
|
|
815
|
+
this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
|
|
816
|
+
await this.enqueueProviderUpdate(undefined);
|
|
972
817
|
}
|
|
973
818
|
// Unknown provider: idempotent success.
|
|
974
819
|
return {};
|
|
975
820
|
}
|
|
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
821
|
resolveProviderConfig() {
|
|
982
|
-
|
|
983
|
-
|
|
822
|
+
return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
823
|
+
}
|
|
824
|
+
defaultProviderConfig() {
|
|
825
|
+
const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
826
|
+
if (gatewayConfig) {
|
|
827
|
+
return gatewayConfig;
|
|
828
|
+
}
|
|
829
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
|
830
|
+
return {
|
|
831
|
+
apiType: "bedrock",
|
|
832
|
+
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
|
833
|
+
headers: {},
|
|
834
|
+
};
|
|
984
835
|
}
|
|
985
|
-
|
|
836
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
|
837
|
+
return {
|
|
838
|
+
apiType: "vertex",
|
|
839
|
+
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
|
|
840
|
+
headers: {},
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
apiType: "anthropic",
|
|
845
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
|
846
|
+
headers: {},
|
|
847
|
+
};
|
|
986
848
|
}
|
|
987
849
|
async logout(_params) {
|
|
988
850
|
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
|
@@ -1012,6 +874,8 @@ export class ClaudeAcpAgent {
|
|
|
1012
874
|
}
|
|
1013
875
|
}
|
|
1014
876
|
async prompt(params) {
|
|
877
|
+
if (this.providerUpdate)
|
|
878
|
+
await this.providerUpdate;
|
|
1015
879
|
const session = this.sessions[params.sessionId];
|
|
1016
880
|
if (!session) {
|
|
1017
881
|
throw new Error("Session not found");
|
|
@@ -1093,6 +957,12 @@ export class ClaudeAcpAgent {
|
|
|
1093
957
|
}
|
|
1094
958
|
return { stopReason: "end_turn" };
|
|
1095
959
|
}
|
|
960
|
+
if (session.autoModeFallbackWarningPending) {
|
|
961
|
+
await this.sessionModes.publishFallbackWarning(params.sessionId, session);
|
|
962
|
+
}
|
|
963
|
+
if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
|
|
964
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
965
|
+
}
|
|
1096
966
|
// Lazy Thinking application (R1.3): a pending config change is applied by
|
|
1097
967
|
// recreating the SDK query with session resume BEFORE this turn is
|
|
1098
968
|
// enqueued — never mid-turn, so only when no other prompt is in flight. A
|
|
@@ -1126,6 +996,16 @@ export class ClaudeAcpAgent {
|
|
|
1126
996
|
// user message, so the consumer can't promote the turn from the echo.
|
|
1127
997
|
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
1128
998
|
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
999
|
+
const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
1000
|
+
? agentFileChangeReportRequestId(params._meta)
|
|
1001
|
+
: undefined;
|
|
1002
|
+
let fileChangeAudit;
|
|
1003
|
+
if (fileChangeReportRequestId &&
|
|
1004
|
+
!session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
|
|
1005
|
+
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
|
|
1006
|
+
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
|
|
1007
|
+
}
|
|
1008
|
+
session.titles.onPrompt(params.prompt);
|
|
1129
1009
|
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
1130
1010
|
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
1131
1011
|
// turn, pushes the user message onto the streaming input, makes sure the
|
|
@@ -1133,13 +1013,24 @@ export class ClaudeAcpAgent {
|
|
|
1133
1013
|
const turn = {
|
|
1134
1014
|
promptUuid,
|
|
1135
1015
|
isLocalOnlyCommand,
|
|
1016
|
+
...(fileChangeAudit ? { fileChangeAudit } : {}),
|
|
1136
1017
|
settled: false,
|
|
1137
1018
|
resolve: () => { },
|
|
1138
1019
|
reject: () => { },
|
|
1139
1020
|
};
|
|
1021
|
+
let completeTurn;
|
|
1022
|
+
turn.completion = new Promise((resolve) => {
|
|
1023
|
+
completeTurn = resolve;
|
|
1024
|
+
});
|
|
1140
1025
|
const response = new Promise((resolve, reject) => {
|
|
1141
|
-
turn.resolve =
|
|
1142
|
-
|
|
1026
|
+
turn.resolve = (result) => {
|
|
1027
|
+
resolve(result);
|
|
1028
|
+
completeTurn();
|
|
1029
|
+
};
|
|
1030
|
+
turn.reject = (error) => {
|
|
1031
|
+
reject(error);
|
|
1032
|
+
completeTurn();
|
|
1033
|
+
};
|
|
1143
1034
|
});
|
|
1144
1035
|
session.turnQueue ??= [];
|
|
1145
1036
|
session.turnQueue.push(turn);
|
|
@@ -1174,6 +1065,15 @@ export class ClaudeAcpAgent {
|
|
|
1174
1065
|
},
|
|
1175
1066
|
});
|
|
1176
1067
|
}
|
|
1068
|
+
async publishTaskPlan(sessionId, taskState) {
|
|
1069
|
+
await this.client.sessionUpdate({
|
|
1070
|
+
sessionId,
|
|
1071
|
+
update: {
|
|
1072
|
+
sessionUpdate: "plan",
|
|
1073
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1177
1077
|
async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
|
|
1178
1078
|
const goalUpdate = goalUpdateFromPrompt(prompt);
|
|
1179
1079
|
if (goalUpdate !== undefined) {
|
|
@@ -1213,11 +1113,14 @@ export class ClaudeAcpAgent {
|
|
|
1213
1113
|
* an `SDKUserMessage` onto the same streaming input, which the SDK routes
|
|
1214
1114
|
* into the in-flight turn. The injected message's echo carries a uuid that
|
|
1215
1115
|
* matches no queued turn, so the consumer drops it as an unrelated replay
|
|
1216
|
-
* without promoting/settling anything. It is delivered at {@link
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
* calls).
|
|
1220
|
-
*
|
|
1116
|
+
* without promoting/settling anything. It is normally delivered at {@link
|
|
1117
|
+
* STEER_PRIORITY_NOW} so it pre-empts the current generation (interrupting a
|
|
1118
|
+
* single-shot response, or slotting in between a multi-step turn's tool
|
|
1119
|
+
* calls). While a permission or elicitation is awaiting user input it uses
|
|
1120
|
+
* {@link STEER_PRIORITY_LATER} instead, because interrupting that SDK
|
|
1121
|
+
* callback cancels the ACP request and can strand the prompt (IJAI-1191).
|
|
1122
|
+
* The steered message's own output streams via `session/update`, not this
|
|
1123
|
+
* response.
|
|
1221
1124
|
*
|
|
1222
1125
|
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
|
1223
1126
|
* own and the steered message runs as a second one, so the turn is marked
|
|
@@ -1268,8 +1171,11 @@ export class ClaudeAcpAgent {
|
|
|
1268
1171
|
const steeredUuid = randomUUID();
|
|
1269
1172
|
userMessage.uuid = steeredUuid;
|
|
1270
1173
|
// Deliver into the running turn rather than queuing behind it as a fresh
|
|
1271
|
-
// prompt would.
|
|
1272
|
-
|
|
1174
|
+
// prompt would. A steer landing while the client has a permission or
|
|
1175
|
+
// elicitation card open must not pre-empt it — the interrupt cancels that
|
|
1176
|
+
// ACP request — so it waits for the LAST such request to settle instead.
|
|
1177
|
+
userMessage.priority =
|
|
1178
|
+
(session.pendingUserInputCount ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
|
|
1273
1179
|
// Mark before the push and in the same synchronous section as the in-flight
|
|
1274
1180
|
// check: the interrupt can have the CLI finalizing the aborted cycle by the
|
|
1275
1181
|
// time the consumer next runs, and an unmarked result would settle the turn
|
|
@@ -1287,6 +1193,15 @@ export class ClaudeAcpAgent {
|
|
|
1287
1193
|
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
|
1288
1194
|
return { outcome: "injected" };
|
|
1289
1195
|
}
|
|
1196
|
+
/** Publish the audit terminal for every turn path that did not reach the
|
|
1197
|
+
* report tool. The support flips the turn state synchronously before its
|
|
1198
|
+
* transport await, so callers can stay fail-open and settle the ACP prompt
|
|
1199
|
+
* immediately without allowing a racing lifecycle path to publish twice. */
|
|
1200
|
+
finishFileChangeAudit(session, turn, reason) {
|
|
1201
|
+
if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
|
|
1202
|
+
return;
|
|
1203
|
+
void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
|
|
1204
|
+
}
|
|
1290
1205
|
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
1291
1206
|
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
1292
1207
|
ensureConsumer(session, sessionId) {
|
|
@@ -1321,6 +1236,8 @@ export class ClaudeAcpAgent {
|
|
|
1321
1236
|
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
1322
1237
|
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
1323
1238
|
let lastAssistantError;
|
|
1239
|
+
let lastAssistantWasUsageLimit = false;
|
|
1240
|
+
let lastAssistantFailureTitle;
|
|
1324
1241
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
1325
1242
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
1326
1243
|
// human-readable explanation so the terminal `result` can surface it.
|
|
@@ -1366,19 +1283,67 @@ export class ClaudeAcpAgent {
|
|
|
1366
1283
|
* as the turn's answer. */
|
|
1367
1284
|
const sendUpdate = async (notification) => {
|
|
1368
1285
|
const { update } = notification;
|
|
1286
|
+
if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
|
|
1287
|
+
(update.sessionUpdate === "agent_message_chunk" ||
|
|
1288
|
+
update.sessionUpdate === "agent_thought_chunk" ||
|
|
1289
|
+
update.sessionUpdate === "user_message_chunk" ||
|
|
1290
|
+
update.sessionUpdate === "tool_call" ||
|
|
1291
|
+
update.sessionUpdate === "tool_call_update")) {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1369
1294
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1370
1295
|
const claudeMeta = update._meta?.claudeCode;
|
|
1371
1296
|
if (!claudeMeta?.parentToolUseId) {
|
|
1372
1297
|
session.emittedAssistantText = true;
|
|
1298
|
+
session.titles.onAssistantText(update.content);
|
|
1373
1299
|
}
|
|
1374
1300
|
}
|
|
1375
1301
|
await this.client.sessionUpdate(notification);
|
|
1376
1302
|
};
|
|
1303
|
+
let pendingWorkerShutdown = false;
|
|
1304
|
+
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
1305
|
+
const sessionFailures = new SessionFailureController({
|
|
1306
|
+
sessionId: params.sessionId,
|
|
1307
|
+
state: session.sessionFailureState,
|
|
1308
|
+
capabilities: this.clientCapabilities,
|
|
1309
|
+
isCurrent: isCurrentConsumer,
|
|
1310
|
+
sendUpdate,
|
|
1311
|
+
logger: this.logger,
|
|
1312
|
+
});
|
|
1313
|
+
const createSessionFailure = async (kind, options = {}) => {
|
|
1314
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1315
|
+
return sessionFailures.prepare(kind, {
|
|
1316
|
+
turnId,
|
|
1317
|
+
sessionScoped: options.turnScoped === false,
|
|
1318
|
+
title: options.title,
|
|
1319
|
+
details: options.details,
|
|
1320
|
+
severity: options.severity,
|
|
1321
|
+
});
|
|
1322
|
+
};
|
|
1323
|
+
const publishSessionFailure = async (kind, options = {}) => {
|
|
1324
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1325
|
+
await sessionFailures.publish(kind, {
|
|
1326
|
+
turnId,
|
|
1327
|
+
sessionScoped: options.turnScoped === false,
|
|
1328
|
+
title: options.title,
|
|
1329
|
+
details: options.details,
|
|
1330
|
+
severity: options.severity,
|
|
1331
|
+
});
|
|
1332
|
+
};
|
|
1333
|
+
const clearFailuresFromEarlierTurns = async () => {
|
|
1334
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
1335
|
+
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
|
|
1336
|
+
// They are session-scoped and stay until superseded or dismissed by the user.
|
|
1337
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
|
|
1338
|
+
};
|
|
1339
|
+
const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
|
|
1377
1340
|
const resetTurnScratch = () => {
|
|
1378
1341
|
lastAssistantTotalUsage = null;
|
|
1379
1342
|
lastAssistantUsage = null;
|
|
1380
1343
|
lastAssistantModel = null;
|
|
1381
1344
|
lastAssistantError = undefined;
|
|
1345
|
+
lastAssistantWasUsageLimit = false;
|
|
1346
|
+
lastAssistantFailureTitle = undefined;
|
|
1382
1347
|
lastRefusalExplanation = null;
|
|
1383
1348
|
compactionInProgress = false;
|
|
1384
1349
|
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
@@ -1390,12 +1355,14 @@ export class ClaudeAcpAgent {
|
|
|
1390
1355
|
// cleared when each consolidated message consumes it. #785 stopped
|
|
1391
1356
|
// resetting the streamed-content tracking here but left this line.
|
|
1392
1357
|
stopReason = "end_turn";
|
|
1393
|
-
session.accumulatedUsage = {
|
|
1358
|
+
session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
|
|
1394
1359
|
inputTokens: 0,
|
|
1395
1360
|
outputTokens: 0,
|
|
1396
1361
|
cachedReadTokens: 0,
|
|
1397
1362
|
cachedWriteTokens: 0,
|
|
1398
1363
|
};
|
|
1364
|
+
if (session.activeTurn)
|
|
1365
|
+
session.activeTurn.carriedUsage = undefined;
|
|
1399
1366
|
};
|
|
1400
1367
|
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
1401
1368
|
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
@@ -1622,11 +1589,14 @@ export class ClaudeAcpAgent {
|
|
|
1622
1589
|
};
|
|
1623
1590
|
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
1624
1591
|
* backstop (the turn is over), and drop it from the queue. */
|
|
1625
|
-
const settleActive = (result
|
|
1592
|
+
const settleActive = (result, auditReason = result.stopReason === "cancelled"
|
|
1593
|
+
? "cancelled"
|
|
1594
|
+
: "notReported") => {
|
|
1626
1595
|
const turn = session.activeTurn;
|
|
1627
1596
|
if (!turn || turn.settled) {
|
|
1628
1597
|
return;
|
|
1629
1598
|
}
|
|
1599
|
+
this.finishFileChangeAudit(session, turn, auditReason);
|
|
1630
1600
|
// Captured before the settled flip below (isHeldOpen tests !settled).
|
|
1631
1601
|
const wasHeld = isHeldOpen(turn);
|
|
1632
1602
|
turn.settled = true;
|
|
@@ -1657,8 +1627,10 @@ export class ClaudeAcpAgent {
|
|
|
1657
1627
|
disarmForceCancel(session);
|
|
1658
1628
|
const turn = session.activeTurn;
|
|
1659
1629
|
if (!turn || turn.settled) {
|
|
1630
|
+
this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
|
|
1660
1631
|
return;
|
|
1661
1632
|
}
|
|
1633
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1662
1634
|
turn.settled = true;
|
|
1663
1635
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
1664
1636
|
session.activeTurn = null;
|
|
@@ -1670,6 +1642,31 @@ export class ClaudeAcpAgent {
|
|
|
1670
1642
|
session.emittedAssistantText = false;
|
|
1671
1643
|
turn.reject(error);
|
|
1672
1644
|
};
|
|
1645
|
+
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1646
|
+
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1647
|
+
* JSON-RPC rejection path. */
|
|
1648
|
+
const failActiveWithSessionFailure = async (kind, error, title) => {
|
|
1649
|
+
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1650
|
+
failActive(error);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!session.activeTurn || session.activeTurn.settled) {
|
|
1654
|
+
this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
|
|
1655
|
+
await publishSessionFailure(kind, { turnScoped: false, title });
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const failure = await createSessionFailure(kind, { title });
|
|
1659
|
+
if (!failure) {
|
|
1660
|
+
failActive(error);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
sessionFailures.recordActive(failure);
|
|
1664
|
+
settleActive({
|
|
1665
|
+
stopReason: "end_turn",
|
|
1666
|
+
usage: sessionUsage(session),
|
|
1667
|
+
_meta: sessionFailureMeta(failure),
|
|
1668
|
+
}, "providerError");
|
|
1669
|
+
};
|
|
1673
1670
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1674
1671
|
const failAllTurns = (error) => {
|
|
1675
1672
|
disarmForceCancel(session);
|
|
@@ -1680,6 +1677,7 @@ export class ClaudeAcpAgent {
|
|
|
1680
1677
|
session.turnQueue = [];
|
|
1681
1678
|
for (const turn of turns) {
|
|
1682
1679
|
if (!turn.settled) {
|
|
1680
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1683
1681
|
const wasHeld = isHeldOpen(turn);
|
|
1684
1682
|
turn.settled = true;
|
|
1685
1683
|
if (wasHeld) {
|
|
@@ -1752,7 +1750,9 @@ export class ClaudeAcpAgent {
|
|
|
1752
1750
|
// spent would never drain (it would swallow an unrelated later
|
|
1753
1751
|
// echo-less result instead).
|
|
1754
1752
|
const active = session.activeTurn;
|
|
1755
|
-
if (active.commandFinished === "completed" ||
|
|
1753
|
+
if (active.commandFinished === "completed" ||
|
|
1754
|
+
active.commandFinished === "discarded" ||
|
|
1755
|
+
active.commandFinished === "refused") {
|
|
1756
1756
|
// Finished SDK-side; any result already passed. Nothing to
|
|
1757
1757
|
// track.
|
|
1758
1758
|
}
|
|
@@ -1812,6 +1812,24 @@ export class ClaudeAcpAgent {
|
|
|
1812
1812
|
if (session.query !== myQuery) {
|
|
1813
1813
|
return;
|
|
1814
1814
|
}
|
|
1815
|
+
if (pendingWorkerShutdown) {
|
|
1816
|
+
pendingWorkerShutdown = false;
|
|
1817
|
+
if (session.activeTurn) {
|
|
1818
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
1819
|
+
await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
|
|
1820
|
+
}
|
|
1821
|
+
else {
|
|
1822
|
+
// The held turn already has its authoritative terminal outcome,
|
|
1823
|
+
// but EOF permanently closes the non-revivable Query. Preserve
|
|
1824
|
+
// the turn result below and report the independent session-health
|
|
1825
|
+
// failure without a turnId so AIR can offer a new session.
|
|
1826
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1815
1833
|
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
1816
1834
|
// stream resources — same order as the error paths (failAllTurns before
|
|
1817
1835
|
// closeQueryStream). Settling is the user-facing contract; resource
|
|
@@ -1835,6 +1853,7 @@ export class ClaudeAcpAgent {
|
|
|
1835
1853
|
// still here was enqueued afterward and was not part of the cancel.)
|
|
1836
1854
|
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
1837
1855
|
if (!queued.settled) {
|
|
1856
|
+
this.finishFileChangeAudit(session, queued, "providerError");
|
|
1838
1857
|
queued.settled = true;
|
|
1839
1858
|
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
1840
1859
|
}
|
|
@@ -1856,7 +1875,7 @@ export class ClaudeAcpAgent {
|
|
|
1856
1875
|
}
|
|
1857
1876
|
// CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
|
|
1858
1877
|
// uuid-stamped queued command (queued/started/completed/cancelled/
|
|
1859
|
-
// discarded) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1878
|
+
// discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1860
1879
|
// prompt() stamps a uuid on every message. The frame is @internal and
|
|
1861
1880
|
// absent from the SDKMessage union, so handle it BEFORE the exhaustive
|
|
1862
1881
|
// switch: it must not reach `unreachable`'s error log, and a `case`
|
|
@@ -1891,6 +1910,7 @@ export class ClaudeAcpAgent {
|
|
|
1891
1910
|
}
|
|
1892
1911
|
case "completed":
|
|
1893
1912
|
case "discarded":
|
|
1913
|
+
case "refused":
|
|
1894
1914
|
case "cancelled": {
|
|
1895
1915
|
// Terminal frames. Latch the fate on a still-queued turn so a
|
|
1896
1916
|
// later cancel() doesn't seed an orphan entry for a command
|
|
@@ -1924,6 +1944,9 @@ export class ClaudeAcpAgent {
|
|
|
1924
1944
|
// command folded into another turn whose result is attributed
|
|
1925
1945
|
// elsewhere — either way no echo-less result remains to skip.
|
|
1926
1946
|
// "discarded" = session ended with it still queued; no result.
|
|
1947
|
+
// "refused" (2.1.238+) = a cross-session peer message declined
|
|
1948
|
+
// by receive-side policy before dispatch; never a prompt-lane
|
|
1949
|
+
// command of ours, and no result will ever come.
|
|
1927
1950
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
1928
1951
|
break;
|
|
1929
1952
|
}
|
|
@@ -1963,6 +1986,23 @@ export class ClaudeAcpAgent {
|
|
|
1963
1986
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
1964
1987
|
// session creation.
|
|
1965
1988
|
await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
1989
|
+
// Terminal-bound slash commands (absent when none, and on
|
|
1990
|
+
// older CLIs). The session/new advertisement runs before any
|
|
1991
|
+
// init frame can be observed, so the first latch (or a
|
|
1992
|
+
// genuine change) re-publishes the now-filtered list.
|
|
1993
|
+
if (message.terminal_slash_commands &&
|
|
1994
|
+
JSON.stringify(message.terminal_slash_commands) !==
|
|
1995
|
+
JSON.stringify(session.terminalSlashCommands)) {
|
|
1996
|
+
session.terminalSlashCommands = message.terminal_slash_commands;
|
|
1997
|
+
try {
|
|
1998
|
+
await this.sendAvailableCommandsUpdate(message.session_id);
|
|
1999
|
+
}
|
|
2000
|
+
catch (error) {
|
|
2001
|
+
// Advisory reconcile only — the client keeps its current
|
|
2002
|
+
// (unfiltered) list; never fail the turn over it.
|
|
2003
|
+
this.logger.error(`Failed to re-advertise slash commands: ${error}`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
1966
2006
|
break;
|
|
1967
2007
|
case "status": {
|
|
1968
2008
|
// These banners count as delivered text (via sendUpdate), so
|
|
@@ -2030,6 +2070,7 @@ export class ClaudeAcpAgent {
|
|
|
2030
2070
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
2031
2071
|
lastAssistantUsage = null;
|
|
2032
2072
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
2073
|
+
session.contextUsedTokens = usedTokens ?? 0;
|
|
2033
2074
|
await sendUpdate({
|
|
2034
2075
|
sessionId: message.session_id,
|
|
2035
2076
|
update: {
|
|
@@ -2155,13 +2196,11 @@ export class ClaudeAcpAgent {
|
|
|
2155
2196
|
// the next prompt; only a timer could tell those apart.
|
|
2156
2197
|
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
2157
2198
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
2158
|
-
|
|
2199
|
+
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
2159
2200
|
}
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
|
|
2163
|
-
// landed. Push it to the client if it changed.
|
|
2164
|
-
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
2201
|
+
// Turn-over is when a title may have landed or become
|
|
2202
|
+
// generatable; see SessionTitles.onTurnEnd.
|
|
2203
|
+
await session.titles.onTurnEnd(session);
|
|
2165
2204
|
}
|
|
2166
2205
|
break;
|
|
2167
2206
|
}
|
|
@@ -2213,7 +2252,7 @@ export class ClaudeAcpAgent {
|
|
|
2213
2252
|
sessionId: message.session_id,
|
|
2214
2253
|
update: {
|
|
2215
2254
|
sessionUpdate: "available_commands_update",
|
|
2216
|
-
availableCommands: getAvailableSlashCommands(message.commands),
|
|
2255
|
+
availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
|
|
2217
2256
|
},
|
|
2218
2257
|
});
|
|
2219
2258
|
break;
|
|
@@ -2355,9 +2394,14 @@ export class ClaudeAcpAgent {
|
|
|
2355
2394
|
}
|
|
2356
2395
|
break;
|
|
2357
2396
|
case "worker_shutting_down":
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2397
|
+
// Defer until stream end. The announcement is durable and may be
|
|
2398
|
+
// replayed before later frames, but those frames do not prove that
|
|
2399
|
+
// a new worker epoch began: they can be buffered output from the
|
|
2400
|
+
// shutting-down worker. Keep the signal armed until the transport
|
|
2401
|
+
// actually ends. Deliberately do not add a quiet-period timer: the
|
|
2402
|
+
// iterator has no replay/live boundary, so a timeout could publish
|
|
2403
|
+
// while a slow replay is still in flight.
|
|
2404
|
+
pendingWorkerShutdown = true;
|
|
2361
2405
|
break;
|
|
2362
2406
|
case "elicitation_complete": {
|
|
2363
2407
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
@@ -2377,10 +2421,21 @@ export class ClaudeAcpAgent {
|
|
|
2377
2421
|
}
|
|
2378
2422
|
case "plugin_install":
|
|
2379
2423
|
case "notification":
|
|
2380
|
-
case "api_retry":
|
|
2381
2424
|
case "thinking_tokens":
|
|
2382
2425
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
2383
2426
|
break;
|
|
2427
|
+
case "api_retry": {
|
|
2428
|
+
const title = message.error_status === null
|
|
2429
|
+
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
|
|
2430
|
+
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
|
|
2431
|
+
await publishSessionFailure(message.error_status === null
|
|
2432
|
+
? "transport_lost"
|
|
2433
|
+
: providerFailureCategory(message.error), {
|
|
2434
|
+
title,
|
|
2435
|
+
severity: "warning",
|
|
2436
|
+
});
|
|
2437
|
+
break;
|
|
2438
|
+
}
|
|
2384
2439
|
case "model_refusal_fallback": {
|
|
2385
2440
|
// The SDK retried a refused turn on the fallback model and made
|
|
2386
2441
|
// the swap persistent for the session. Without a notice the
|
|
@@ -2394,26 +2449,53 @@ export class ClaudeAcpAgent {
|
|
|
2394
2449
|
// CLIs, where "revert" marked a turn-only fallback — for that
|
|
2395
2450
|
// direction the session stays on the original model, so skip
|
|
2396
2451
|
// the persistent-swap claim and the state sync.
|
|
2397
|
-
|
|
2452
|
+
//
|
|
2453
|
+
// `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
|
|
2454
|
+
// "local" means a subagent / side-question / background fork
|
|
2455
|
+
// response fell back and the session model is unchanged, so
|
|
2456
|
+
// syncing the picker would advertise a model the session
|
|
2457
|
+
// isn't running. Absent scope means an older CLI, where every
|
|
2458
|
+
// retry was a session-level swap — treat as "session".
|
|
2459
|
+
const local = message.scope === "local";
|
|
2460
|
+
const persistent = message.direction !== "revert" && !local;
|
|
2398
2461
|
const category = message.api_refusal_category
|
|
2399
2462
|
? ` (${message.api_refusal_category})`
|
|
2400
2463
|
: "";
|
|
2401
|
-
const explanation = message.api_refusal_explanation
|
|
2402
|
-
? `\n\n${message.api_refusal_explanation}`
|
|
2403
|
-
: "";
|
|
2404
2464
|
const outcome = persistent
|
|
2405
2465
|
? `The session will continue on ${message.fallback_model}.`
|
|
2406
|
-
:
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2466
|
+
: local
|
|
2467
|
+
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
|
2468
|
+
: `The session stays on ${message.original_model}.`;
|
|
2469
|
+
const fallbackSummary = `${message.original_model} declined this request${category}; ` +
|
|
2470
|
+
`retried with ${message.fallback_model}. ${outcome}`;
|
|
2471
|
+
const explanation = message.api_refusal_explanation || undefined;
|
|
2472
|
+
const fallbackNotice = explanation
|
|
2473
|
+
? `${fallbackSummary}\n\n${explanation}`
|
|
2474
|
+
: fallbackSummary;
|
|
2475
|
+
// A silent model swap is a session-level advisory, not something the model said.
|
|
2476
|
+
// Clients that negotiated typed records get it as one; the rest keep the bold-label
|
|
2477
|
+
// transcript line, which was the only way to flag it before.
|
|
2478
|
+
if (supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2479
|
+
const useDetails = explanation !== undefined &&
|
|
2480
|
+
fallbackSummary.length + 2 + explanation.length >
|
|
2481
|
+
MAX_INLINE_FAILURE_TITLE_LENGTH;
|
|
2482
|
+
await publishSessionFailure("advisory", {
|
|
2483
|
+
title: useDetails ? fallbackSummary : fallbackNotice,
|
|
2484
|
+
...(useDetails ? { details: explanation } : {}),
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
else {
|
|
2488
|
+
await sendUpdate({
|
|
2489
|
+
sessionId: message.session_id,
|
|
2490
|
+
update: {
|
|
2491
|
+
sessionUpdate: "agent_message_chunk",
|
|
2492
|
+
content: {
|
|
2493
|
+
type: "text",
|
|
2494
|
+
text: `**Model fallback:** ${fallbackNotice}`,
|
|
2495
|
+
},
|
|
2414
2496
|
},
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2417
2499
|
if (persistent) {
|
|
2418
2500
|
await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
|
|
2419
2501
|
}
|
|
@@ -2498,6 +2580,8 @@ export class ClaudeAcpAgent {
|
|
|
2498
2580
|
// user-turn lifecycle (stop reason, settles, failActive,
|
|
2499
2581
|
// slash-command output forwarding), though their cost is real.
|
|
2500
2582
|
const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
|
|
2583
|
+
const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
|
|
2584
|
+
const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
|
|
2501
2585
|
try {
|
|
2502
2586
|
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
2503
2587
|
// Gated to user-driven turns like every other side effect below;
|
|
@@ -2650,7 +2734,10 @@ export class ClaudeAcpAgent {
|
|
|
2650
2734
|
});
|
|
2651
2735
|
}
|
|
2652
2736
|
if (session.cancelled) {
|
|
2737
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2738
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2653
2739
|
if (!isAutonomousResult) {
|
|
2740
|
+
await clearFailuresFromEarlierTurns();
|
|
2654
2741
|
stopReason = "cancelled";
|
|
2655
2742
|
}
|
|
2656
2743
|
break;
|
|
@@ -2686,6 +2773,37 @@ export class ClaudeAcpAgent {
|
|
|
2686
2773
|
}
|
|
2687
2774
|
break;
|
|
2688
2775
|
}
|
|
2776
|
+
await clearFailuresFromEarlierTurns();
|
|
2777
|
+
// `interrupt: true` is required to make "No, keep planning"
|
|
2778
|
+
// terminate the ACP turn. Claude represents that intentional
|
|
2779
|
+
// interrupt as an error-shaped diagnostic, so translate only a
|
|
2780
|
+
// diagnostic causally paired with the recorded ExitPlanMode
|
|
2781
|
+
// permission response.
|
|
2782
|
+
const diagnostic = executionDiagnostic(message);
|
|
2783
|
+
if (pendingExitPlanModeInterruption &&
|
|
2784
|
+
pendingExitPlanModeInterruption.toolResultSeen &&
|
|
2785
|
+
message.is_error &&
|
|
2786
|
+
diagnostic &&
|
|
2787
|
+
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
|
2788
|
+
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
|
|
2789
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2790
|
+
if (pendingExitPlanContextReset &&
|
|
2791
|
+
pendingExitPlanContextReset.toolUseId ===
|
|
2792
|
+
pendingExitPlanModeInterruption.toolUseId) {
|
|
2793
|
+
await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
stopReason = "cancelled";
|
|
2797
|
+
settleOrDefer({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
2798
|
+
break;
|
|
2799
|
+
}
|
|
2800
|
+
if (pendingExitPlanModeInterruption) {
|
|
2801
|
+
// This result ended the interrupted cycle without the exact
|
|
2802
|
+
// correlated cancellation shape. Never carry its marker into
|
|
2803
|
+
// a later turn, whether or not the tool result was observed.
|
|
2804
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2805
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2806
|
+
}
|
|
2689
2807
|
// A refusal can arrive on any result subtype (and may even set
|
|
2690
2808
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2691
2809
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2722,10 +2840,15 @@ export class ClaudeAcpAgent {
|
|
|
2722
2840
|
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
2723
2841
|
break;
|
|
2724
2842
|
}
|
|
2843
|
+
if (!message.is_error && lastAssistantModel !== null) {
|
|
2844
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
2845
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
|
|
2846
|
+
(failure.severity === "warning" && failure.turnId === activeTurnId));
|
|
2847
|
+
}
|
|
2725
2848
|
switch (message.subtype) {
|
|
2726
2849
|
case "success": {
|
|
2727
2850
|
if (message.result.includes("Please run /login")) {
|
|
2728
|
-
|
|
2851
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
|
|
2729
2852
|
break;
|
|
2730
2853
|
}
|
|
2731
2854
|
if (message.stop_reason === "max_tokens") {
|
|
@@ -2733,7 +2856,7 @@ export class ClaudeAcpAgent {
|
|
|
2733
2856
|
break;
|
|
2734
2857
|
}
|
|
2735
2858
|
if (message.is_error) {
|
|
2736
|
-
|
|
2859
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
|
|
2737
2860
|
break;
|
|
2738
2861
|
}
|
|
2739
2862
|
// The result text is forwarded in two cases. Local-only
|
|
@@ -2769,17 +2892,29 @@ export class ClaudeAcpAgent {
|
|
|
2769
2892
|
break;
|
|
2770
2893
|
}
|
|
2771
2894
|
if (message.is_error) {
|
|
2772
|
-
|
|
2895
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
|
|
2773
2896
|
break;
|
|
2774
2897
|
}
|
|
2775
2898
|
stopReason = "end_turn";
|
|
2776
2899
|
break;
|
|
2777
2900
|
}
|
|
2778
2901
|
case "error_max_budget_usd":
|
|
2902
|
+
if (message.is_error) {
|
|
2903
|
+
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2904
|
+
break;
|
|
2905
|
+
}
|
|
2906
|
+
stopReason = "max_turn_requests";
|
|
2907
|
+
break;
|
|
2779
2908
|
case "error_max_turns":
|
|
2909
|
+
if (message.is_error) {
|
|
2910
|
+
await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2911
|
+
break;
|
|
2912
|
+
}
|
|
2913
|
+
stopReason = "max_turn_requests";
|
|
2914
|
+
break;
|
|
2780
2915
|
case "error_max_structured_output_retries":
|
|
2781
2916
|
if (message.is_error) {
|
|
2782
|
-
|
|
2917
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2783
2918
|
break;
|
|
2784
2919
|
}
|
|
2785
2920
|
stopReason = "max_turn_requests";
|
|
@@ -2917,6 +3052,7 @@ export class ClaudeAcpAgent {
|
|
|
2917
3052
|
const nextUsage = totalTokens(lastAssistantUsage);
|
|
2918
3053
|
if (nextUsage !== lastAssistantTotalUsage) {
|
|
2919
3054
|
lastAssistantTotalUsage = nextUsage;
|
|
3055
|
+
session.contextUsedTokens = nextUsage;
|
|
2920
3056
|
await sendUpdate({
|
|
2921
3057
|
sessionId: params.sessionId,
|
|
2922
3058
|
update: {
|
|
@@ -3053,6 +3189,11 @@ export class ClaudeAcpAgent {
|
|
|
3053
3189
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
3054
3190
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
3055
3191
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3192
|
+
session.contextUsedTokens = lastAssistantTotalUsage;
|
|
3193
|
+
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3194
|
+
if (message.error || lastAssistantWasUsageLimit) {
|
|
3195
|
+
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
3196
|
+
}
|
|
3056
3197
|
if (message.message.model && message.message.model !== "<synthetic>") {
|
|
3057
3198
|
lastAssistantModel = message.message.model;
|
|
3058
3199
|
}
|
|
@@ -3111,7 +3252,18 @@ export class ClaudeAcpAgent {
|
|
|
3111
3252
|
break;
|
|
3112
3253
|
}
|
|
3113
3254
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3114
|
-
|
|
3255
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
|
|
3256
|
+
break;
|
|
3257
|
+
}
|
|
3258
|
+
// AIR receives this provider condition on the terminal prompt
|
|
3259
|
+
// response as a typed failure whose title is the exact assistant
|
|
3260
|
+
// error text captured above. Do not duplicate that text as an
|
|
3261
|
+
// ordinary assistant message; legacy clients retain the historical
|
|
3262
|
+
// transcript behavior.
|
|
3263
|
+
if (message.type === "assistant" &&
|
|
3264
|
+
message.parent_tool_use_id === null &&
|
|
3265
|
+
(message.error || isSyntheticUsageLimitMessage(message.message)) &&
|
|
3266
|
+
supportsAirSessionFailures(this.clientCapabilities)) {
|
|
3115
3267
|
break;
|
|
3116
3268
|
}
|
|
3117
3269
|
let content;
|
|
@@ -3188,6 +3340,7 @@ export class ClaudeAcpAgent {
|
|
|
3188
3340
|
else {
|
|
3189
3341
|
content = message.message.content;
|
|
3190
3342
|
}
|
|
3343
|
+
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
|
3191
3344
|
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
3192
3345
|
clientCapabilities: this.clientCapabilities,
|
|
3193
3346
|
parentToolUseId: message.parent_tool_use_id,
|
|
@@ -3206,7 +3359,7 @@ export class ClaudeAcpAgent {
|
|
|
3206
3359
|
// filtered out of `content` above; blocks that do pass through
|
|
3207
3360
|
// (e.g. a subagent image) carry the stamped parentToolUseId
|
|
3208
3361
|
// meta and are excluded there.
|
|
3209
|
-
await sendUpdate(notification);
|
|
3362
|
+
await sendUpdate(acceptedPlanToolResult(notification, acceptedPlanToolUseId));
|
|
3210
3363
|
}
|
|
3211
3364
|
break;
|
|
3212
3365
|
}
|
|
@@ -3273,13 +3426,26 @@ export class ClaudeAcpAgent {
|
|
|
3273
3426
|
}
|
|
3274
3427
|
break;
|
|
3275
3428
|
}
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3429
|
+
case "conversation_reset": {
|
|
3430
|
+
// The SDK has switched to a fresh conversation, whose Task* IDs
|
|
3431
|
+
// and task store are independent of the previous transcript.
|
|
3432
|
+
// Clear both the in-memory snapshot and the client's visible plan
|
|
3433
|
+
// before any follow-up prompt can republish stale tasks.
|
|
3434
|
+
session.taskState.clear();
|
|
3435
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3436
|
+
// A reset mounts a fresh transcript (`new_conversation_id`), so our
|
|
3437
|
+
// cached title no longer describes the session: drop it and
|
|
3438
|
+
// re-evaluate at the next turn-end.
|
|
3439
|
+
session.titles.reset();
|
|
3440
|
+
break;
|
|
3441
|
+
}
|
|
3279
3442
|
case "tool_use_summary":
|
|
3280
|
-
case "auth_status":
|
|
3281
3443
|
case "prompt_suggestion":
|
|
3282
|
-
|
|
3444
|
+
break;
|
|
3445
|
+
case "auth_status":
|
|
3446
|
+
if (!message.isAuthenticating && message.error === undefined) {
|
|
3447
|
+
await sessionFailures.clear((failure) => failure.kind === "auth_required");
|
|
3448
|
+
}
|
|
3283
3449
|
break;
|
|
3284
3450
|
default:
|
|
3285
3451
|
unreachable(message, this.logger);
|
|
@@ -3308,6 +3474,19 @@ export class ClaudeAcpAgent {
|
|
|
3308
3474
|
message.includes("process exited with") ||
|
|
3309
3475
|
message.includes("process terminated by signal") ||
|
|
3310
3476
|
message.includes("Failed to write to process stdin"));
|
|
3477
|
+
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3478
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
3479
|
+
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
3480
|
+
}
|
|
3481
|
+
else {
|
|
3482
|
+
// The held turn keeps its recorded PromptResponse outcome, while the
|
|
3483
|
+
// exhausted Query makes the session independently unrecoverable.
|
|
3484
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
else {
|
|
3488
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3489
|
+
}
|
|
3311
3490
|
// Either way the query iterator is finished and the consumer is exiting,
|
|
3312
3491
|
// so release its resources via closeQueryStream (idempotent). A process
|
|
3313
3492
|
// death is unrecoverable, so additionally evict the session so the client
|
|
@@ -3321,7 +3500,9 @@ export class ClaudeAcpAgent {
|
|
|
3321
3500
|
}
|
|
3322
3501
|
else {
|
|
3323
3502
|
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
3324
|
-
failAllTurns(
|
|
3503
|
+
failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
|
|
3504
|
+
? internalErrorForClient({ errorKind: "transport_lost" })
|
|
3505
|
+
: error);
|
|
3325
3506
|
this.closeQueryStream(session);
|
|
3326
3507
|
}
|
|
3327
3508
|
}
|
|
@@ -3351,6 +3532,7 @@ export class ClaudeAcpAgent {
|
|
|
3351
3532
|
}
|
|
3352
3533
|
}
|
|
3353
3534
|
async cancel(params) {
|
|
3535
|
+
this.exitPlan.cancel(params.sessionId);
|
|
3354
3536
|
const session = this.sessions[params.sessionId];
|
|
3355
3537
|
if (!session) {
|
|
3356
3538
|
return;
|
|
@@ -3364,6 +3546,9 @@ export class ClaudeAcpAgent {
|
|
|
3364
3546
|
if (session.queryRecreateInFlight) {
|
|
3365
3547
|
await session.queryRecreateInFlight;
|
|
3366
3548
|
}
|
|
3549
|
+
session.cancelled = true;
|
|
3550
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3551
|
+
session.pendingExitPlanContextReset = undefined;
|
|
3367
3552
|
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
3368
3553
|
// settled when it closed, and there is no live query to interrupt. Calling
|
|
3369
3554
|
// query.interrupt() on a finished iterator could reject and surface from
|
|
@@ -3371,7 +3556,6 @@ export class ClaudeAcpAgent {
|
|
|
3371
3556
|
if (session.queryClosed) {
|
|
3372
3557
|
return;
|
|
3373
3558
|
}
|
|
3374
|
-
session.cancelled = true;
|
|
3375
3559
|
// A priority steer may still be queued in the SDK when cancellation
|
|
3376
3560
|
// settles its owning turn. Its later echo matches no live turn, and its
|
|
3377
3561
|
// result must be skipped rather than promoted onto the next prompt.
|
|
@@ -3395,6 +3579,7 @@ export class ClaudeAcpAgent {
|
|
|
3395
3579
|
if (session.turnQueue) {
|
|
3396
3580
|
for (const turn of session.turnQueue) {
|
|
3397
3581
|
if (turn !== session.activeTurn && !turn.settled) {
|
|
3582
|
+
this.finishFileChangeAudit(session, turn, "cancelled");
|
|
3398
3583
|
turn.settled = true;
|
|
3399
3584
|
// Deliberately no `usage`: a queued turn never ran, so the session
|
|
3400
3585
|
// accumulator (the active turn's tally) is not its spend.
|
|
@@ -3414,7 +3599,9 @@ export class ClaudeAcpAgent {
|
|
|
3414
3599
|
// never see lifecycle frames, so commandStarted/commandFinished stay
|
|
3415
3600
|
// unset and every turn takes the plain-seed path below).
|
|
3416
3601
|
for (const turn of orphanedTurns) {
|
|
3417
|
-
if (turn.commandFinished === "completed" ||
|
|
3602
|
+
if (turn.commandFinished === "completed" ||
|
|
3603
|
+
turn.commandFinished === "discarded" ||
|
|
3604
|
+
turn.commandFinished === "refused") {
|
|
3418
3605
|
// The command already finished SDK-side and its terminal frame was
|
|
3419
3606
|
// consumed while the turn sat queued — nothing is left to skip, and
|
|
3420
3607
|
// a seeded entry would never drain.
|
|
@@ -3456,6 +3643,7 @@ export class ClaudeAcpAgent {
|
|
|
3456
3643
|
{
|
|
3457
3644
|
const active = session.activeTurn;
|
|
3458
3645
|
if (isHeldOpen(active)) {
|
|
3646
|
+
this.finishFileChangeAudit(session, active, "cancelled");
|
|
3459
3647
|
active.settled = true;
|
|
3460
3648
|
// Mirror settleActive's invariants (it is consumer-scoped and
|
|
3461
3649
|
// unreachable from here): disarm the backstop — none should be
|
|
@@ -3639,27 +3827,19 @@ export class ClaudeAcpAgent {
|
|
|
3639
3827
|
return {};
|
|
3640
3828
|
}
|
|
3641
3829
|
async setSessionMode(params) {
|
|
3642
|
-
const session = this.sessions[params.sessionId];
|
|
3643
|
-
if (!session) {
|
|
3644
|
-
throw new Error("Session not found");
|
|
3645
|
-
}
|
|
3646
3830
|
// A lazy Thinking recreate may be swapping the session's query right now;
|
|
3647
3831
|
// join it (mirroring prompt()) so `setPermissionMode` below lands on the
|
|
3648
3832
|
// replacement query after cutover instead of an about-to-close one — and
|
|
3649
3833
|
// so the mode isn't silently dropped from a query built from the
|
|
3650
3834
|
// pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
|
|
3651
|
-
|
|
3835
|
+
// The session-not-found and SESSION_ENDED guards the fork inlined here now
|
|
3836
|
+
// live in `SessionModeManager.requireOpenSession`, which raises the same
|
|
3837
|
+
// errors, so they are not duplicated in front of the delegation below.
|
|
3838
|
+
const session = this.sessions[params.sessionId];
|
|
3839
|
+
if (session?.queryRecreateInFlight) {
|
|
3652
3840
|
await session.queryRecreateInFlight;
|
|
3653
3841
|
}
|
|
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 {};
|
|
3842
|
+
return this.sessionModes.setSessionMode(params);
|
|
3663
3843
|
}
|
|
3664
3844
|
async setSessionConfigOption(params) {
|
|
3665
3845
|
const session = this.sessions[params.sessionId];
|
|
@@ -3687,10 +3867,9 @@ export class ClaudeAcpAgent {
|
|
|
3687
3867
|
if (!option) {
|
|
3688
3868
|
throw new Error(`Unknown config option: ${params.configId}`);
|
|
3689
3869
|
}
|
|
3690
|
-
// Fast mode
|
|
3691
|
-
//
|
|
3692
|
-
//
|
|
3693
|
-
// rely on.
|
|
3870
|
+
// Fast mode carries a boolean value (for Clients that opted into boolean
|
|
3871
|
+
// config options) or the "on"/"off" select fallback, so it bypasses the
|
|
3872
|
+
// string-only validation the select-style options below rely on.
|
|
3694
3873
|
if (params.configId === FAST_MODE_CONFIG_ID) {
|
|
3695
3874
|
await this.applyFastMode(session, resolveFastModeEnabled(params));
|
|
3696
3875
|
return { configOptions: session.configOptions };
|
|
@@ -3759,15 +3938,10 @@ export class ClaudeAcpAgent {
|
|
|
3759
3938
|
// Use the canonical option value so downstream code always receives the
|
|
3760
3939
|
// model ID rather than the caller-supplied alias.
|
|
3761
3940
|
const resolvedValue = validValue.value;
|
|
3941
|
+
let effectiveValue = resolvedValue;
|
|
3762
3942
|
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
|
-
});
|
|
3943
|
+
effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
|
|
3944
|
+
await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
|
3771
3945
|
}
|
|
3772
3946
|
else if (params.configId === MODEL_CONFIG_ID) {
|
|
3773
3947
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
@@ -3775,50 +3949,41 @@ export class ClaudeAcpAgent {
|
|
|
3775
3949
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
3776
3950
|
// effort changes and effort changes induced by a model switch go through
|
|
3777
3951
|
// the same path.
|
|
3778
|
-
await this.applyConfigOptionValue(params.sessionId, session, params.configId,
|
|
3952
|
+
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
|
3779
3953
|
return { configOptions: session.configOptions };
|
|
3780
3954
|
}
|
|
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
3955
|
async replaySessionHistory(sessionId) {
|
|
3817
3956
|
const toolUseCache = {};
|
|
3818
3957
|
const messages = await getSessionMessages(sessionId);
|
|
3819
|
-
const
|
|
3820
|
-
|
|
3958
|
+
const session = this.sessions[sessionId];
|
|
3959
|
+
const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
|
|
3960
|
+
const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
|
|
3961
|
+
const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
|
|
3962
|
+
const sessionFailures = session && supportsTypedFailures
|
|
3963
|
+
? new SessionFailureController({
|
|
3964
|
+
sessionId,
|
|
3965
|
+
state: session.sessionFailureState,
|
|
3966
|
+
capabilities: this.clientCapabilities,
|
|
3967
|
+
isCurrent: () => this.sessions[sessionId] === session,
|
|
3968
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
3969
|
+
logger: this.logger,
|
|
3970
|
+
})
|
|
3971
|
+
: undefined;
|
|
3972
|
+
let replayTurnId;
|
|
3973
|
+
// Stop-hook additionalContext is persisted as an internal user message.
|
|
3974
|
+
// Once that marker (or the internal tool itself) appears, suppress the
|
|
3975
|
+
// whole audit exchange until its tool result. This also hides a disobedient
|
|
3976
|
+
// model's separate prose message, while an ordinary next user prompt safely
|
|
3977
|
+
// ends an incomplete audit lane.
|
|
3978
|
+
let replayingFileChangeAudit = false;
|
|
3979
|
+
const replayFileChangeAuditToolUseIds = new Set();
|
|
3821
3980
|
for (const message of messages) {
|
|
3981
|
+
if (message.type === "user" &&
|
|
3982
|
+
message.parent_tool_use_id === null &&
|
|
3983
|
+
typeof message.uuid === "string" &&
|
|
3984
|
+
message.uuid.length > 0) {
|
|
3985
|
+
replayTurnId = message.uuid;
|
|
3986
|
+
}
|
|
3822
3987
|
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
3823
3988
|
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
3824
3989
|
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
@@ -3834,6 +3999,21 @@ export class ClaudeAcpAgent {
|
|
|
3834
3999
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3835
4000
|
continue;
|
|
3836
4001
|
}
|
|
4002
|
+
// Capable clients saw every synthetic usage-limit message as a typed
|
|
4003
|
+
// failure live, so replay it at the same transcript position. The
|
|
4004
|
+
// preceding persisted user uuid is the live prompt uuid and therefore
|
|
4005
|
+
// recreates the same incident identity. Only the latest limit not
|
|
4006
|
+
// followed by a real model answer remains active internally.
|
|
4007
|
+
if (sessionFailures &&
|
|
4008
|
+
message.type === "assistant" &&
|
|
4009
|
+
message.parent_tool_use_id === null &&
|
|
4010
|
+
isSyntheticUsageLimitMessage(message.message)) {
|
|
4011
|
+
const title = assistantMessageText(message.message);
|
|
4012
|
+
if (title) {
|
|
4013
|
+
await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
|
|
4014
|
+
}
|
|
4015
|
+
continue;
|
|
4016
|
+
}
|
|
3837
4017
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3838
4018
|
let content = message.message.content;
|
|
3839
4019
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
@@ -3846,6 +4026,57 @@ export class ClaudeAcpAgent {
|
|
|
3846
4026
|
if (content === null)
|
|
3847
4027
|
continue;
|
|
3848
4028
|
}
|
|
4029
|
+
const auditBlocks = Array.isArray(content)
|
|
4030
|
+
? content.filter((block) => typeof block === "object" && block !== null)
|
|
4031
|
+
: [];
|
|
4032
|
+
const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
|
|
4033
|
+
auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
|
|
4034
|
+
const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
|
|
4035
|
+
block.type === "server_tool_use" ||
|
|
4036
|
+
block.type === "mcp_tool_use") &&
|
|
4037
|
+
typeof block.name === "string" &&
|
|
4038
|
+
isFileChangeAuditTool(block.name) &&
|
|
4039
|
+
typeof block.id === "string"
|
|
4040
|
+
? [block.id]
|
|
4041
|
+
: []);
|
|
4042
|
+
const replayMessageRole = message.message
|
|
4043
|
+
?.role;
|
|
4044
|
+
if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
|
|
4045
|
+
replayingFileChangeAudit = true;
|
|
4046
|
+
for (const toolUseId of fileChangeAuditToolUseIds) {
|
|
4047
|
+
replayFileChangeAuditToolUseIds.add(toolUseId);
|
|
4048
|
+
}
|
|
4049
|
+
continue;
|
|
4050
|
+
}
|
|
4051
|
+
if (replayingFileChangeAudit) {
|
|
4052
|
+
const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
|
|
4053
|
+
typeof block.tool_use_id === "string"
|
|
4054
|
+
? [block.tool_use_id]
|
|
4055
|
+
: []);
|
|
4056
|
+
let completedReport = false;
|
|
4057
|
+
for (const toolUseId of toolResultIds) {
|
|
4058
|
+
if (replayFileChangeAuditToolUseIds.delete(toolUseId))
|
|
4059
|
+
completedReport = true;
|
|
4060
|
+
}
|
|
4061
|
+
if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
|
|
4062
|
+
replayingFileChangeAudit = false;
|
|
4063
|
+
continue;
|
|
4064
|
+
}
|
|
4065
|
+
// A denied attempt to call another tool is still part of the hidden
|
|
4066
|
+
// lane. Its result must not end replay suppression before the report.
|
|
4067
|
+
if (toolResultIds.length > 0) {
|
|
4068
|
+
continue;
|
|
4069
|
+
}
|
|
4070
|
+
// The next real user prompt is already represented by the client and
|
|
4071
|
+
// starts a new turn; do not let a missing audit result hide it or the
|
|
4072
|
+
// rest of the replay.
|
|
4073
|
+
if (replayMessageRole === "user") {
|
|
4074
|
+
replayingFileChangeAudit = false;
|
|
4075
|
+
}
|
|
4076
|
+
else {
|
|
4077
|
+
continue;
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
3849
4080
|
for (const notification of toAcpNotifications(
|
|
3850
4081
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3851
4082
|
content,
|
|
@@ -3870,23 +4101,52 @@ export class ClaudeAcpAgent {
|
|
|
3870
4101
|
const response = await this.client.writeTextFile(params);
|
|
3871
4102
|
return response;
|
|
3872
4103
|
}
|
|
4104
|
+
/** Mark a client request as blocking on user input for exactly the lifetime
|
|
4105
|
+
* of its promise. Steering consults this session-local count synchronously,
|
|
4106
|
+
* so a message arriving while any permission/elicitation card is open uses
|
|
4107
|
+
* non-interrupting SDK delivery. The decrement sits in `finally` (and is
|
|
4108
|
+
* clamped at zero), so accepted, declined, cancelled, aborted and failed
|
|
4109
|
+
* requests all settle the count — a request that ends by throwing must not
|
|
4110
|
+
* pin the session at `later` for the rest of its life. */
|
|
4111
|
+
async withPendingUserInput(sessionId, request) {
|
|
4112
|
+
const session = this.sessions[sessionId];
|
|
4113
|
+
if (!session)
|
|
4114
|
+
return request();
|
|
4115
|
+
session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
|
|
4116
|
+
try {
|
|
4117
|
+
return await request();
|
|
4118
|
+
}
|
|
4119
|
+
finally {
|
|
4120
|
+
// Decrement the SAME object the increment ran on, not a fresh
|
|
4121
|
+
// `this.sessions[sessionId]` lookup: a session replaced mid-request would
|
|
4122
|
+
// otherwise be charged for a request it never made. Both the clamp and
|
|
4123
|
+
// the `?? 1` land such a replacement's own count on 0, never below it.
|
|
4124
|
+
session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
3873
4127
|
/** Forward a permission request to the client, wiring the tool call's
|
|
3874
4128
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
3875
4129
|
* 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. */
|
|
4130
|
+
* `$/cancel_request`, and our local abort race settles even if the client
|
|
4131
|
+
* ignores it. A `cancelled` outcome, request rejection, and local abort all
|
|
4132
|
+
* surface the same "Tool use aborted" the callers already expect. */
|
|
3880
4133
|
async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
|
|
4134
|
+
if (signal.aborted)
|
|
4135
|
+
throw new Error("Tool use aborted");
|
|
3881
4136
|
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
3882
4137
|
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
3883
4138
|
// expect the `tool_call` a permission request references to already exist,
|
|
3884
4139
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
3885
4140
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
3886
4141
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
3887
|
-
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
|
|
4142
|
+
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
|
|
4143
|
+
if (signal.aborted)
|
|
4144
|
+
throw new Error("Tool use aborted");
|
|
4145
|
+
// Do not rely on every ACP client settling requestPermission after the
|
|
4146
|
+
// cancellation signal. The local race guarantees that Claude's tool call
|
|
4147
|
+
// is released even when an older or broken client ignores $/cancel_request.
|
|
3888
4148
|
try {
|
|
3889
|
-
return await this.client.requestPermission(params, signal);
|
|
4149
|
+
return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
|
|
3890
4150
|
}
|
|
3891
4151
|
catch (error) {
|
|
3892
4152
|
if (signal.aborted) {
|
|
@@ -3907,7 +4167,7 @@ export class ClaudeAcpAgent {
|
|
|
3907
4167
|
* are resolved at tool_result time instead (see `toAcpNotifications`).
|
|
3908
4168
|
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
|
|
3909
4169
|
* that spawned it, matching the streamed path's `_meta`. */
|
|
3910
|
-
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
|
|
4170
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal) {
|
|
3911
4171
|
const session = this.sessions[sessionId];
|
|
3912
4172
|
if (!session) {
|
|
3913
4173
|
return;
|
|
@@ -3927,10 +4187,20 @@ export class ClaudeAcpAgent {
|
|
|
3927
4187
|
},
|
|
3928
4188
|
};
|
|
3929
4189
|
}
|
|
3930
|
-
|
|
4190
|
+
try {
|
|
4191
|
+
const emission = this.client.sessionUpdate({ sessionId, update });
|
|
4192
|
+
await (signal ? raceWithAbort(emission, signal) : emission);
|
|
4193
|
+
}
|
|
4194
|
+
catch (error) {
|
|
4195
|
+
// The set is also the de-duplication guard for the later streamed
|
|
4196
|
+
// tool_use. Keep it truthful: if emission failed, that path must still
|
|
4197
|
+
// be allowed to publish the tool call instead of refining a phantom one.
|
|
4198
|
+
session.emittedToolCalls.delete(toolCallId);
|
|
4199
|
+
throw error;
|
|
4200
|
+
}
|
|
3931
4201
|
}
|
|
3932
4202
|
canUseTool(sessionId) {
|
|
3933
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
|
|
4203
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
|
|
3934
4204
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
3935
4205
|
const session = this.sessions[sessionId];
|
|
3936
4206
|
if (!session) {
|
|
@@ -3939,6 +4209,26 @@ export class ClaudeAcpAgent {
|
|
|
3939
4209
|
message: "Session not found",
|
|
3940
4210
|
};
|
|
3941
4211
|
}
|
|
4212
|
+
const fileChangeAudit = session.activeTurn?.fileChangeAudit;
|
|
4213
|
+
if (isFileChangeAuditReportPhase(fileChangeAudit)) {
|
|
4214
|
+
// The hidden continuation is an audit-only lane: it may submit the
|
|
4215
|
+
// wrapper-owned report, but it must not run another command after the
|
|
4216
|
+
// user-visible answer has already completed.
|
|
4217
|
+
if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
|
|
4218
|
+
return { behavior: "allow", updatedInput: toolInput };
|
|
4219
|
+
}
|
|
4220
|
+
return {
|
|
4221
|
+
behavior: "deny",
|
|
4222
|
+
message: "Only the internal file-change report is allowed during the audit.",
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4225
|
+
// The tool is intentionally unusable outside a negotiated audit turn.
|
|
4226
|
+
if (isFileChangeAuditTool(toolName)) {
|
|
4227
|
+
return {
|
|
4228
|
+
behavior: "deny",
|
|
4229
|
+
message: "No file-change report was requested for this turn.",
|
|
4230
|
+
};
|
|
4231
|
+
}
|
|
3942
4232
|
// When the tool call originates inside a subagent, attribute the eagerly
|
|
3943
4233
|
// emitted tool_call (and the permission request itself) to the Agent/Task
|
|
3944
4234
|
// tool call that spawned the subagent, mirroring the streamed subagent
|
|
@@ -3963,155 +4253,81 @@ export class ClaudeAcpAgent {
|
|
|
3963
4253
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
3964
4254
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
3965
4255
|
// make sure the tool_call has surfaced to the client before we send it.
|
|
3966
|
-
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
|
|
4256
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
|
|
3967
4257
|
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
3968
4258
|
}
|
|
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
|
-
],
|
|
4259
|
+
// Do not auto-allow here based on the session's advertised mode. Claude
|
|
4260
|
+
// Code applies bypassPermissions before invoking canUseTool; a request
|
|
4261
|
+
// that still reaches this callback is deliberately bypass-immune (for
|
|
4262
|
+
// example a safety check, a tool requiring user interaction, or an
|
|
4263
|
+
// explicit ask rule). Re-applying bypass in the host would erase that
|
|
4264
|
+
// provider safety decision.
|
|
4265
|
+
const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
|
|
4266
|
+
const presentation = buildClaudePermissionPresentation({
|
|
4267
|
+
toolName,
|
|
4268
|
+
input: toolInput,
|
|
4269
|
+
toolUseID,
|
|
4270
|
+
cwd: session.cwd,
|
|
4271
|
+
supportsTerminalOutput,
|
|
4272
|
+
blockedPath,
|
|
4273
|
+
title,
|
|
4274
|
+
displayName,
|
|
4275
|
+
description,
|
|
4276
|
+
decisionReason,
|
|
4277
|
+
});
|
|
4278
|
+
if (parentToolUseId) {
|
|
4279
|
+
presentation.toolCall._meta = {
|
|
4280
|
+
claudeCode: { toolName, parentToolUseId },
|
|
4054
4281
|
};
|
|
4055
4282
|
}
|
|
4283
|
+
const permissionOptions = buildClaudePermissionOptions({
|
|
4284
|
+
toolName,
|
|
4285
|
+
displayName,
|
|
4286
|
+
input: toolInput,
|
|
4287
|
+
cwd: session.cwd,
|
|
4288
|
+
durableChangeSet,
|
|
4289
|
+
allowPersistentOptions: matchedAskRule === undefined,
|
|
4290
|
+
availableModes: this.sessionModes.availableModeIds(session.modes),
|
|
4291
|
+
contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
|
4292
|
+
? undefined
|
|
4293
|
+
: Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
|
|
4294
|
+
});
|
|
4056
4295
|
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
|
-
],
|
|
4296
|
+
...presentation,
|
|
4297
|
+
options: permissionOptions,
|
|
4072
4298
|
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
4299
|
}, toolName, signal, parentToolUseId);
|
|
4084
|
-
if (signal.aborted
|
|
4300
|
+
if (signal.aborted)
|
|
4085
4301
|
throw new Error("Tool use aborted");
|
|
4302
|
+
const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
|
|
4303
|
+
let permissionResult = decodedPermission.permissionResult;
|
|
4304
|
+
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
|
|
4305
|
+
permissionResult = autoFallback.permissionResult;
|
|
4306
|
+
if (autoFallback.fallbackApplied) {
|
|
4307
|
+
await this.sessionModes.publishFallbackWarning(sessionId, session);
|
|
4086
4308
|
}
|
|
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,
|
|
4309
|
+
const clearContextMode = decodedPermission.contextResetMode
|
|
4310
|
+
? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
|
|
4311
|
+
: undefined;
|
|
4312
|
+
if (toolName === "ExitPlanMode" && clearContextMode) {
|
|
4313
|
+
const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
|
|
4314
|
+
if (!plan)
|
|
4315
|
+
throw new Error("ExitPlanMode clear-context selection requires a plan");
|
|
4316
|
+
session.pendingExitPlanContextReset = {
|
|
4317
|
+
toolUseId: toolUseID,
|
|
4318
|
+
plan,
|
|
4319
|
+
mode: clearContextMode,
|
|
4107
4320
|
};
|
|
4108
4321
|
}
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4322
|
+
if (toolName === "ExitPlanMode" &&
|
|
4323
|
+
permissionResult.behavior === "deny" &&
|
|
4324
|
+
permissionResult.interrupt === true) {
|
|
4325
|
+
session.pendingExitPlanModeInterruption = {
|
|
4326
|
+
toolUseId: toolUseID,
|
|
4327
|
+
toolResultSeen: false,
|
|
4113
4328
|
};
|
|
4114
4329
|
}
|
|
4330
|
+
return permissionResult;
|
|
4115
4331
|
};
|
|
4116
4332
|
}
|
|
4117
4333
|
/**
|
|
@@ -4130,7 +4346,7 @@ export class ClaudeAcpAgent {
|
|
|
4130
4346
|
return { action: "decline" };
|
|
4131
4347
|
}
|
|
4132
4348
|
try {
|
|
4133
|
-
const response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
4349
|
+
const response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
|
|
4134
4350
|
if (signal.aborted) {
|
|
4135
4351
|
return { action: "cancel" };
|
|
4136
4352
|
}
|
|
@@ -4160,7 +4376,7 @@ export class ClaudeAcpAgent {
|
|
|
4160
4376
|
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
4161
4377
|
let response;
|
|
4162
4378
|
try {
|
|
4163
|
-
response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
4379
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
|
|
4164
4380
|
}
|
|
4165
4381
|
catch (error) {
|
|
4166
4382
|
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
@@ -4200,7 +4416,7 @@ export class ClaudeAcpAgent {
|
|
|
4200
4416
|
}
|
|
4201
4417
|
let response;
|
|
4202
4418
|
try {
|
|
4203
|
-
response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
|
|
4419
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
|
|
4204
4420
|
}
|
|
4205
4421
|
catch (error) {
|
|
4206
4422
|
// A cancellation we requested (signal aborted) is expected teardown;
|
|
@@ -4227,7 +4443,7 @@ export class ClaudeAcpAgent {
|
|
|
4227
4443
|
sessionId,
|
|
4228
4444
|
update: {
|
|
4229
4445
|
sessionUpdate: "available_commands_update",
|
|
4230
|
-
availableCommands: getAvailableSlashCommands(commands),
|
|
4446
|
+
availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
|
|
4231
4447
|
},
|
|
4232
4448
|
});
|
|
4233
4449
|
}
|
|
@@ -4246,12 +4462,11 @@ export class ClaudeAcpAgent {
|
|
|
4246
4462
|
}
|
|
4247
4463
|
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
4248
4464
|
if (configId === MODE_CONFIG_ID) {
|
|
4249
|
-
|
|
4250
|
-
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4465
|
+
this.sessionModes.syncConfig(session, value);
|
|
4251
4466
|
}
|
|
4252
4467
|
else if (configId === MODEL_CONFIG_ID) {
|
|
4253
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for
|
|
4254
|
-
//
|
|
4468
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
|
|
4469
|
+
// the Auto fallback below; its `displayName`/`description` also let us infer the
|
|
4255
4470
|
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
4256
4471
|
// carries no "1m" token.
|
|
4257
4472
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
@@ -4273,41 +4488,7 @@ export class ClaudeAcpAgent {
|
|
|
4273
4488
|
session.contextWindowAuthoritative = seeded.authoritative;
|
|
4274
4489
|
}
|
|
4275
4490
|
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
|
-
}
|
|
4491
|
+
const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
|
|
4311
4492
|
// `model_not_allowed` described the model we just left, so it must not
|
|
4312
4493
|
// follow us onto the new one; the remaining reasons are account- or
|
|
4313
4494
|
// environment-scoped and stay true across a switch. Either way the next
|
|
@@ -4324,6 +4505,7 @@ export class ClaudeAcpAgent {
|
|
|
4324
4505
|
// intent) when a supporting model is selected again.
|
|
4325
4506
|
supported: newModelInfo?.supportsFastMode ?? false,
|
|
4326
4507
|
enabled: session.fastModeEnabled,
|
|
4508
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
4327
4509
|
disabledReason: session.fastModeDisabledReason,
|
|
4328
4510
|
},
|
|
4329
4511
|
// Thinking is model-independent: re-render the retained tri-state
|
|
@@ -4349,13 +4531,7 @@ export class ClaudeAcpAgent {
|
|
|
4349
4531
|
// still precedes the caller's config_option_update so order-sensitive
|
|
4350
4532
|
// clients update currentModeId before re-rendering the option list.
|
|
4351
4533
|
if (modeDowngraded) {
|
|
4352
|
-
await this.
|
|
4353
|
-
sessionId,
|
|
4354
|
-
update: {
|
|
4355
|
-
sessionUpdate: "current_mode_update",
|
|
4356
|
-
currentModeId: "default",
|
|
4357
|
-
},
|
|
4358
|
-
});
|
|
4534
|
+
await this.sessionModes.publishFallbackState(sessionId, session);
|
|
4359
4535
|
}
|
|
4360
4536
|
}
|
|
4361
4537
|
else if (configId === AGENT_CONFIG_ID) {
|
|
@@ -4410,13 +4586,11 @@ export class ClaudeAcpAgent {
|
|
|
4410
4586
|
}
|
|
4411
4587
|
}
|
|
4412
4588
|
/** Replace the Fast mode option in `session.configOptions` so it reflects
|
|
4413
|
-
* `enabled` (and the
|
|
4589
|
+
* `enabled` (and the client's current boolean-capability). A no-op when the
|
|
4414
4590
|
* 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. */
|
|
4591
|
+
* it first. */
|
|
4418
4592
|
refreshFastModeOption(session, enabled) {
|
|
4419
|
-
const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
|
|
4593
|
+
const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities), session.fastModeDisabledReason);
|
|
4420
4594
|
session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
|
|
4421
4595
|
}
|
|
4422
4596
|
/** Toggle Fast mode for a session: push the SDK flag, record the user's
|
|
@@ -4813,7 +4987,10 @@ export class ClaudeAcpAgent {
|
|
|
4813
4987
|
// We want to create a new session id unless it is resume,
|
|
4814
4988
|
// but not resume + forkSession.
|
|
4815
4989
|
let sessionId;
|
|
4816
|
-
if (creationOpts.
|
|
4990
|
+
if (creationOpts.publicSessionId) {
|
|
4991
|
+
sessionId = creationOpts.publicSessionId;
|
|
4992
|
+
}
|
|
4993
|
+
else if (creationOpts.forkSession) {
|
|
4817
4994
|
sessionId = randomUUID();
|
|
4818
4995
|
}
|
|
4819
4996
|
else if (creationOpts.resume) {
|
|
@@ -4872,6 +5049,7 @@ export class ClaudeAcpAgent {
|
|
|
4872
5049
|
}
|
|
4873
5050
|
}
|
|
4874
5051
|
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
|
|
5052
|
+
const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
|
|
4875
5053
|
// Extract options from _meta if provided
|
|
4876
5054
|
const sessionMeta = params._meta;
|
|
4877
5055
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
@@ -4906,6 +5084,30 @@ export class ClaudeAcpAgent {
|
|
|
4906
5084
|
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
|
4907
5085
|
// the same Map that the streaming message handler will read from.
|
|
4908
5086
|
const taskState = new Map();
|
|
5087
|
+
// Resolve every workspace root once. The hidden report tool uses this same
|
|
5088
|
+
// set for lexical path validation, and the SDK receives it below.
|
|
5089
|
+
const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
|
5090
|
+
const additionalDirectories = [
|
|
5091
|
+
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
5092
|
+
...acpAdditionalDirectories,
|
|
5093
|
+
];
|
|
5094
|
+
const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
5095
|
+
? createFileChangeAuditSupport({
|
|
5096
|
+
cwd: params.cwd,
|
|
5097
|
+
additionalDirectories,
|
|
5098
|
+
getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
|
|
5099
|
+
publish: async (result) => {
|
|
5100
|
+
await this.client.sessionUpdate({
|
|
5101
|
+
sessionId,
|
|
5102
|
+
update: {
|
|
5103
|
+
sessionUpdate: "session_info_update",
|
|
5104
|
+
_meta: agentFileChangeReportMeta(result),
|
|
5105
|
+
},
|
|
5106
|
+
});
|
|
5107
|
+
},
|
|
5108
|
+
logError: (message) => this.logger.error(message),
|
|
5109
|
+
})
|
|
5110
|
+
: undefined;
|
|
4909
5111
|
// The exact env the query will be created with. Built (and the provider
|
|
4910
5112
|
// cache key derived from it, below) in one place so the key always
|
|
4911
5113
|
// describes the backend this query actually talks to: `providers/set`,
|
|
@@ -4913,13 +5115,36 @@ export class ClaudeAcpAgent {
|
|
|
4913
5115
|
// config concurrently, so re-resolving it after any of the awaits between
|
|
4914
5116
|
// here and the session registration could disagree with the env baked
|
|
4915
5117
|
// into the query.
|
|
5118
|
+
const resolvedProvider = this.resolveProviderConfig();
|
|
5119
|
+
const providerEnv = createEnvForProvider(resolvedProvider);
|
|
5120
|
+
const configuredSettings = userProvidedOptions?.settings ??
|
|
5121
|
+
(modelConfig
|
|
5122
|
+
? {
|
|
5123
|
+
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
5124
|
+
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
5125
|
+
}
|
|
5126
|
+
: undefined);
|
|
5127
|
+
// Claude Code applies env from settings.json after the subprocess env. Put
|
|
5128
|
+
// an active ACP route in the programmatic settings tier too so user/project
|
|
5129
|
+
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
|
5130
|
+
let settings = configuredSettings;
|
|
5131
|
+
if (resolvedProvider) {
|
|
5132
|
+
const baseSettings = typeof configuredSettings === "string"
|
|
5133
|
+
? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
|
|
5134
|
+
: configuredSettings;
|
|
5135
|
+
settings = {
|
|
5136
|
+
...baseSettings,
|
|
5137
|
+
apiKeyHelper: "",
|
|
5138
|
+
env: { ...baseSettings?.env, ...providerEnv },
|
|
5139
|
+
};
|
|
5140
|
+
}
|
|
4916
5141
|
const env = {
|
|
4917
5142
|
...process.env,
|
|
4918
5143
|
...userProvidedOptions?.env,
|
|
4919
5144
|
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
4920
|
-
// legacy gateway auth request.
|
|
4921
|
-
//
|
|
4922
|
-
...
|
|
5145
|
+
// legacy gateway auth request. Routing is baked into the query at
|
|
5146
|
+
// creation; provider updates recreate loaded queries between turns.
|
|
5147
|
+
...providerEnv,
|
|
4923
5148
|
// Opt-in to session state events like when the agent is idle
|
|
4924
5149
|
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
4925
5150
|
};
|
|
@@ -4928,6 +5153,7 @@ export class ClaudeAcpAgent {
|
|
|
4928
5153
|
// SDK, so per-session `_meta` env routing and ambient process-env routing
|
|
4929
5154
|
// are distinguished exactly as the CLI will see them.
|
|
4930
5155
|
const providerCacheKey = providerCacheKeyFor(env);
|
|
5156
|
+
this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
|
|
4931
5157
|
const options = {
|
|
4932
5158
|
systemPrompt,
|
|
4933
5159
|
settingSources: ["user", "project", "local"],
|
|
@@ -4937,27 +5163,23 @@ export class ClaudeAcpAgent {
|
|
|
4937
5163
|
// `enableFileCheckpointing` still wins.
|
|
4938
5164
|
enableFileCheckpointing: true,
|
|
4939
5165
|
...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
|
-
}),
|
|
5166
|
+
...(settings && { settings }),
|
|
4951
5167
|
env,
|
|
4952
5168
|
// Override certain fields that must be controlled by ACP
|
|
4953
5169
|
cwd: params.cwd,
|
|
4954
5170
|
includePartialMessages: true,
|
|
4955
5171
|
forwardSubagentText,
|
|
4956
|
-
mcpServers: {
|
|
5172
|
+
mcpServers: {
|
|
5173
|
+
...(userProvidedOptions?.mcpServers || {}),
|
|
5174
|
+
...mcpServers,
|
|
5175
|
+
...(fileChangeAuditSupport
|
|
5176
|
+
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
|
5177
|
+
: {}),
|
|
5178
|
+
},
|
|
4957
5179
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4958
5180
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
4959
5181
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
4960
|
-
permissionMode,
|
|
5182
|
+
permissionMode: initialPermissionMode,
|
|
4961
5183
|
canUseTool: this.canUseTool(sessionId),
|
|
4962
5184
|
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
4963
5185
|
// when the client advertised support, so non-supporting clients keep the
|
|
@@ -4987,40 +5209,42 @@ export class ClaudeAcpAgent {
|
|
|
4987
5209
|
tools,
|
|
4988
5210
|
hooks: {
|
|
4989
5211
|
...userProvidedOptions?.hooks,
|
|
5212
|
+
...(fileChangeAuditSupport
|
|
5213
|
+
? {
|
|
5214
|
+
PreToolUse: [
|
|
5215
|
+
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
|
5216
|
+
{ hooks: [fileChangeAuditSupport.preToolUseHook] },
|
|
5217
|
+
],
|
|
5218
|
+
}
|
|
5219
|
+
: {}),
|
|
4990
5220
|
PostToolUse: [
|
|
4991
5221
|
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
4992
5222
|
{
|
|
4993
5223
|
hooks: [
|
|
4994
5224
|
createPostToolUseHook({
|
|
4995
5225
|
onEnterPlanMode: async () => {
|
|
4996
|
-
await this.
|
|
4997
|
-
sessionId,
|
|
4998
|
-
update: {
|
|
4999
|
-
sessionUpdate: "current_mode_update",
|
|
5000
|
-
currentModeId: "plan",
|
|
5001
|
-
},
|
|
5002
|
-
});
|
|
5226
|
+
await this.sessionModes.publishCurrent(sessionId, "plan");
|
|
5003
5227
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
|
5004
5228
|
},
|
|
5005
5229
|
}),
|
|
5006
5230
|
],
|
|
5007
5231
|
},
|
|
5008
5232
|
],
|
|
5233
|
+
...(fileChangeAuditSupport
|
|
5234
|
+
? {
|
|
5235
|
+
Stop: [
|
|
5236
|
+
...(userProvidedOptions?.hooks?.Stop || []),
|
|
5237
|
+
{ hooks: [fileChangeAuditSupport.stopHook] },
|
|
5238
|
+
],
|
|
5239
|
+
}
|
|
5240
|
+
: {}),
|
|
5009
5241
|
TaskCreated: [
|
|
5010
5242
|
...(userProvidedOptions?.hooks?.TaskCreated || []),
|
|
5011
5243
|
{
|
|
5012
5244
|
hooks: [
|
|
5013
5245
|
createTaskHook({
|
|
5014
5246
|
taskState,
|
|
5015
|
-
onChange:
|
|
5016
|
-
await this.client.sessionUpdate({
|
|
5017
|
-
sessionId,
|
|
5018
|
-
update: {
|
|
5019
|
-
sessionUpdate: "plan",
|
|
5020
|
-
entries: taskStateToPlanEntries(taskState),
|
|
5021
|
-
},
|
|
5022
|
-
});
|
|
5023
|
-
},
|
|
5247
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
5024
5248
|
}),
|
|
5025
5249
|
],
|
|
5026
5250
|
},
|
|
@@ -5031,35 +5255,24 @@ export class ClaudeAcpAgent {
|
|
|
5031
5255
|
hooks: [
|
|
5032
5256
|
createTaskHook({
|
|
5033
5257
|
taskState,
|
|
5034
|
-
onChange:
|
|
5035
|
-
await this.client.sessionUpdate({
|
|
5036
|
-
sessionId,
|
|
5037
|
-
update: {
|
|
5038
|
-
sessionUpdate: "plan",
|
|
5039
|
-
entries: taskStateToPlanEntries(taskState),
|
|
5040
|
-
},
|
|
5041
|
-
});
|
|
5042
|
-
},
|
|
5258
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
5043
5259
|
}),
|
|
5044
5260
|
],
|
|
5045
5261
|
},
|
|
5046
5262
|
],
|
|
5047
5263
|
},
|
|
5048
|
-
...creationOpts,
|
|
5264
|
+
...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
|
|
5265
|
+
...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
|
|
5049
5266
|
abortController,
|
|
5050
5267
|
};
|
|
5051
5268
|
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
|
5052
5269
|
// legacy `_meta.additionalRoots` extension for clients that haven't been
|
|
5053
5270
|
// updated yet. Either source is merged with directories supplied via
|
|
5054
5271
|
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
|
|
5055
|
-
|
|
5056
|
-
options.additionalDirectories = [
|
|
5057
|
-
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
5058
|
-
...acpAdditionalDirectories,
|
|
5059
|
-
];
|
|
5272
|
+
options.additionalDirectories = additionalDirectories;
|
|
5060
5273
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
5061
5274
|
// Set our own session id if not resuming an existing session.
|
|
5062
|
-
options.sessionId = sessionId;
|
|
5275
|
+
options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
|
|
5063
5276
|
}
|
|
5064
5277
|
// Handle abort controller from meta options
|
|
5065
5278
|
if (abortController?.signal.aborted) {
|
|
@@ -5114,8 +5327,8 @@ export class ClaudeAcpAgent {
|
|
|
5114
5327
|
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
|
|
5115
5328
|
: hideDeprecatedModels(initializationResult.models, this.logger);
|
|
5116
5329
|
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
5117
|
-
//
|
|
5118
|
-
//
|
|
5330
|
+
// Resolve the current model's capabilities separately from the stable
|
|
5331
|
+
// permission-mode catalog advertised to ACP clients.
|
|
5119
5332
|
// Looked up in the UNfiltered catalog: a session honoring a persisted
|
|
5120
5333
|
// deprecated preference must keep that model's real capabilities (R4.3).
|
|
5121
5334
|
// A resumed session can also be running a model outside the
|
|
@@ -5156,38 +5369,12 @@ export class ClaudeAcpAgent {
|
|
|
5156
5369
|
},
|
|
5157
5370
|
]
|
|
5158
5371
|
: 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
|
-
};
|
|
5372
|
+
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5373
|
+
query: q,
|
|
5374
|
+
requestedMode: initialPermissionMode,
|
|
5375
|
+
currentModelInfo,
|
|
5376
|
+
currentModelId: models.currentModelId,
|
|
5377
|
+
});
|
|
5191
5378
|
const agents = await discoverCustomAgents(q);
|
|
5192
5379
|
// Only adopt the requested agent as the selected value if it's one we
|
|
5193
5380
|
// actually surface in the picker. A built-in (filtered out above) or
|
|
@@ -5214,6 +5401,7 @@ export class ClaudeAcpAgent {
|
|
|
5214
5401
|
const fastMode = {
|
|
5215
5402
|
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
5216
5403
|
enabled: fastModeEnabled,
|
|
5404
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
5217
5405
|
disabledReason: fastModeDisabledReason,
|
|
5218
5406
|
};
|
|
5219
5407
|
const configOptions = buildConfigOptions(modes, models,
|
|
@@ -5279,7 +5467,9 @@ export class ClaudeAcpAgent {
|
|
|
5279
5467
|
// can rebuild an equivalent query without re-running this assembly.
|
|
5280
5468
|
queryOptions: options,
|
|
5281
5469
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
5470
|
+
creationParams: params,
|
|
5282
5471
|
settingsManager,
|
|
5472
|
+
titles: new SessionTitles(this, sessionId),
|
|
5283
5473
|
accumulatedUsage: {
|
|
5284
5474
|
inputTokens: 0,
|
|
5285
5475
|
outputTokens: 0,
|
|
@@ -5293,6 +5483,8 @@ export class ClaudeAcpAgent {
|
|
|
5293
5483
|
// capability lookups and `resolveModelPreference` (refusal fallback),
|
|
5294
5484
|
// which must keep seeing deprecated rows (R4.3, visibility-only filter).
|
|
5295
5485
|
modelInfos,
|
|
5486
|
+
autoModeFallbackWarningShown: false,
|
|
5487
|
+
autoModeFallbackWarningPending,
|
|
5296
5488
|
configOptions,
|
|
5297
5489
|
agents,
|
|
5298
5490
|
currentAgent,
|
|
@@ -5311,6 +5503,9 @@ export class ClaudeAcpAgent {
|
|
|
5311
5503
|
emittedAssistantText: false,
|
|
5312
5504
|
owedTrailingIdles: 0,
|
|
5313
5505
|
messageIdToUuid: new Map(),
|
|
5506
|
+
sessionFailureState: createSessionFailureState(),
|
|
5507
|
+
fileChangeReportRequestIds: new Set(),
|
|
5508
|
+
fileChangeAuditSupport,
|
|
5314
5509
|
};
|
|
5315
5510
|
return {
|
|
5316
5511
|
sessionId,
|
|
@@ -5318,6 +5513,41 @@ export class ClaudeAcpAgent {
|
|
|
5318
5513
|
configOptions,
|
|
5319
5514
|
};
|
|
5320
5515
|
}
|
|
5516
|
+
/**
|
|
5517
|
+
* Provider routing is baked into the environment of each SDK Query. Wait for
|
|
5518
|
+
* all submitted turns to settle, close every query, then resume each Claude
|
|
5519
|
+
* session with the same ID so subsequent turns inherit the new environment.
|
|
5520
|
+
*/
|
|
5521
|
+
async enqueueProviderUpdate(config) {
|
|
5522
|
+
const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
|
|
5523
|
+
const update = previous.then(async () => {
|
|
5524
|
+
const sessions = Object.entries(this.sessions);
|
|
5525
|
+
const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
|
|
5526
|
+
if (activeTurns.length > 0) {
|
|
5527
|
+
this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
|
|
5528
|
+
await Promise.all(activeTurns);
|
|
5529
|
+
}
|
|
5530
|
+
this.providerConfig = config;
|
|
5531
|
+
for (const [sessionId, session] of sessions) {
|
|
5532
|
+
if (this.sessions[sessionId] !== session || !session.creationParams) {
|
|
5533
|
+
continue;
|
|
5534
|
+
}
|
|
5535
|
+
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
|
5536
|
+
this.closeQueryStream(session);
|
|
5537
|
+
delete this.sessions[sessionId];
|
|
5538
|
+
await this.createSession(session.creationParams, { resume: sessionId });
|
|
5539
|
+
}
|
|
5540
|
+
});
|
|
5541
|
+
this.providerUpdate = update;
|
|
5542
|
+
try {
|
|
5543
|
+
await update;
|
|
5544
|
+
}
|
|
5545
|
+
finally {
|
|
5546
|
+
if (this.providerUpdate === update) {
|
|
5547
|
+
this.providerUpdate = null;
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
5550
|
+
}
|
|
5321
5551
|
}
|
|
5322
5552
|
function shouldEmitRawMessage(config, message) {
|
|
5323
5553
|
if (config === true)
|
|
@@ -5405,13 +5635,27 @@ function createEnvForProvider(config) {
|
|
|
5405
5635
|
if (!config) {
|
|
5406
5636
|
return {};
|
|
5407
5637
|
}
|
|
5638
|
+
const resetRouting = {
|
|
5639
|
+
ANTHROPIC_BASE_URL: "",
|
|
5640
|
+
ANTHROPIC_BEDROCK_BASE_URL: "",
|
|
5641
|
+
ANTHROPIC_VERTEX_BASE_URL: "",
|
|
5642
|
+
CLAUDE_CODE_USE_BEDROCK: "0",
|
|
5643
|
+
CLAUDE_CODE_USE_VERTEX: "0",
|
|
5644
|
+
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
|
5645
|
+
CLOUD_ML_REGION: "",
|
|
5646
|
+
AWS_REGION: "",
|
|
5647
|
+
ANTHROPIC_API_KEY: "",
|
|
5648
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
5649
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
5650
|
+
};
|
|
5408
5651
|
const customHeaders = Object.entries(config.headers)
|
|
5409
5652
|
.map(([key, value]) => `${key}: ${value}`)
|
|
5410
5653
|
.join("\n");
|
|
5411
5654
|
if (config.apiType === "bedrock") {
|
|
5412
5655
|
return {
|
|
5656
|
+
...resetRouting,
|
|
5413
5657
|
CLAUDE_CODE_USE_BEDROCK: "1",
|
|
5414
|
-
AWS_BEARER_TOKEN_BEDROCK: "
|
|
5658
|
+
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
|
|
5415
5659
|
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
|
5416
5660
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5417
5661
|
};
|
|
@@ -5420,6 +5664,7 @@ function createEnvForProvider(config) {
|
|
|
5420
5664
|
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
|
5421
5665
|
// validation; fall back to empty strings defensively.
|
|
5422
5666
|
return {
|
|
5667
|
+
...resetRouting,
|
|
5423
5668
|
CLAUDE_CODE_USE_VERTEX: "1",
|
|
5424
5669
|
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
|
|
5425
5670
|
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
|
@@ -5428,9 +5673,10 @@ function createEnvForProvider(config) {
|
|
|
5428
5673
|
};
|
|
5429
5674
|
}
|
|
5430
5675
|
return {
|
|
5676
|
+
...resetRouting,
|
|
5431
5677
|
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
5432
5678
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5433
|
-
ANTHROPIC_AUTH_TOKEN: "
|
|
5679
|
+
ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
|
|
5434
5680
|
};
|
|
5435
5681
|
}
|
|
5436
5682
|
/**
|
|
@@ -5449,50 +5695,6 @@ function isValidBaseUrl(baseUrl) {
|
|
|
5449
5695
|
}
|
|
5450
5696
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
5451
5697
|
}
|
|
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
5698
|
// Translate a UI effort value into the flag-layer payload. The SDK
|
|
5497
5699
|
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
|
5498
5700
|
// and only clears a key when an explicit `null` is sent — see
|
|
@@ -5523,7 +5725,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
|
|
|
5523
5725
|
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
5524
5726
|
// (two options sharing the value, selection silently routing to `null`), so we
|
|
5525
5727
|
// exclude that name from discovery.
|
|
5526
|
-
export const DEFAULT_AGENT_ID = "default";
|
|
5527
5728
|
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
5528
5729
|
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
5529
5730
|
* list if discovery fails so a flaky control request never blocks session
|
|
@@ -5541,13 +5742,12 @@ export async function discoverCustomAgents(q) {
|
|
|
5541
5742
|
* Centralized so the option declarations in `buildConfigOptions` and the
|
|
5542
5743
|
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
|
5543
5744
|
* same identifiers and can't drift apart. */
|
|
5544
|
-
export
|
|
5745
|
+
export { MODE_CONFIG_ID };
|
|
5545
5746
|
export const MODEL_CONFIG_ID = "model";
|
|
5546
|
-
export const EFFORT_CONFIG_ID = "effort";
|
|
5547
5747
|
export const AGENT_CONFIG_ID = "agent";
|
|
5548
5748
|
export const FAST_MODE_CONFIG_ID = "fast";
|
|
5549
|
-
/** Select values
|
|
5550
|
-
* (see {@link createFastModeConfigOption}). */
|
|
5749
|
+
/** Select-fallback values used when the client has not opted into boolean
|
|
5750
|
+
* config options (see {@link createFastModeConfigOption}). */
|
|
5551
5751
|
export const FAST_MODE_ON = "on";
|
|
5552
5752
|
export const FAST_MODE_OFF = "off";
|
|
5553
5753
|
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
|
@@ -5584,33 +5784,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
|
|
|
5584
5784
|
export function normalizeFastModeDisabledReason(reason) {
|
|
5585
5785
|
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
|
5586
5786
|
}
|
|
5587
|
-
/**
|
|
5588
|
-
*
|
|
5589
|
-
*
|
|
5590
|
-
*
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
* `
|
|
5596
|
-
*
|
|
5597
|
-
*
|
|
5598
|
-
* silently refuses to stay on. Ignored while enabled: a reason reported
|
|
5599
|
-
* alongside an `on`/`cooldown` state isn't blocking anything right now.
|
|
5787
|
+
/** Whether the Client advertised support for boolean session config options
|
|
5788
|
+
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
|
|
5789
|
+
* config options to Clients that opt in; otherwise we fall back to a `select`.
|
|
5790
|
+
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
|
|
5791
|
+
export function clientSupportsBooleanConfigOptions(clientCapabilities) {
|
|
5792
|
+
return clientCapabilities?.session?.configOptions?.boolean != null;
|
|
5793
|
+
}
|
|
5794
|
+
/** Build the Fast mode config option. When the Client supports boolean config
|
|
5795
|
+
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
|
|
5796
|
+
* a two-value `select` ("on"/"off") so older Clients still get a usable
|
|
5797
|
+
* control.
|
|
5600
5798
|
*
|
|
5601
|
-
*
|
|
5602
|
-
*
|
|
5603
|
-
*
|
|
5604
|
-
*
|
|
5605
|
-
|
|
5799
|
+
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
|
|
5800
|
+
* description while the toggle reads off, so a user whose account or provider
|
|
5801
|
+
* can't serve Fast mode sees why instead of a switch that silently refuses to
|
|
5802
|
+
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
|
|
5803
|
+
* state isn't blocking anything right now. */
|
|
5804
|
+
export function createFastModeConfigOption(enabled, useBooleanOption, disabledReason) {
|
|
5606
5805
|
const explanation = enabled
|
|
5607
5806
|
? undefined
|
|
5608
5807
|
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
|
5609
|
-
|
|
5808
|
+
const base = {
|
|
5610
5809
|
id: FAST_MODE_CONFIG_ID,
|
|
5611
5810
|
name: "Fast mode",
|
|
5612
5811
|
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
|
|
5613
5812
|
category: "model_config",
|
|
5813
|
+
};
|
|
5814
|
+
if (useBooleanOption) {
|
|
5815
|
+
return { ...base, type: "boolean", currentValue: enabled };
|
|
5816
|
+
}
|
|
5817
|
+
return {
|
|
5818
|
+
...base,
|
|
5614
5819
|
type: "select",
|
|
5615
5820
|
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
|
5616
5821
|
options: [
|
|
@@ -5620,8 +5825,8 @@ export function createFastModeConfigOption(enabled, disabledReason) {
|
|
|
5620
5825
|
};
|
|
5621
5826
|
}
|
|
5622
5827
|
/** Resolve the requested Fast mode value from a `session/set_config_option`
|
|
5623
|
-
* request. Accepts
|
|
5624
|
-
*
|
|
5828
|
+
* request. Accepts a native boolean (boolean-capable Clients) or the
|
|
5829
|
+
* "on"/"off" select-fallback strings. */
|
|
5625
5830
|
export function resolveFastModeEnabled(params) {
|
|
5626
5831
|
const value = params.value;
|
|
5627
5832
|
if (typeof value === "boolean") {
|
|
@@ -5643,19 +5848,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
5643
5848
|
* callers/tests) omits the row. */
|
|
5644
5849
|
thinkingEnabled) {
|
|
5645
5850
|
const options = [
|
|
5646
|
-
|
|
5647
|
-
id: MODE_CONFIG_ID,
|
|
5648
|
-
name: "Mode",
|
|
5649
|
-
description: "Session permission mode",
|
|
5650
|
-
category: "mode",
|
|
5651
|
-
type: "select",
|
|
5652
|
-
currentValue: modes.currentModeId,
|
|
5653
|
-
options: modes.availableModes.map((m) => ({
|
|
5654
|
-
value: m.id,
|
|
5655
|
-
name: m.name,
|
|
5656
|
-
description: m.description,
|
|
5657
|
-
})),
|
|
5658
|
-
},
|
|
5851
|
+
SessionModeManager.configOption(modes),
|
|
5659
5852
|
{
|
|
5660
5853
|
id: MODEL_CONFIG_ID,
|
|
5661
5854
|
name: "Model",
|
|
@@ -5663,11 +5856,21 @@ thinkingEnabled) {
|
|
|
5663
5856
|
category: "model",
|
|
5664
5857
|
type: "select",
|
|
5665
5858
|
currentValue: models.currentModelId,
|
|
5666
|
-
options: models.availableModels.map((m) =>
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5859
|
+
options: models.availableModels.map((m) => {
|
|
5860
|
+
if (m.modelId === "default") {
|
|
5861
|
+
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
|
|
5862
|
+
const resolvedModel = defaultInfo?.resolvedModel;
|
|
5863
|
+
if (resolvedModel) {
|
|
5864
|
+
const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
|
|
5865
|
+
return {
|
|
5866
|
+
value: m.modelId,
|
|
5867
|
+
name: m.name,
|
|
5868
|
+
description: namedMatch?.displayName ?? resolvedModel,
|
|
5869
|
+
};
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
5872
|
+
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
|
|
5873
|
+
}),
|
|
5671
5874
|
},
|
|
5672
5875
|
];
|
|
5673
5876
|
// Add effort level option based on the currently selected model
|
|
@@ -5699,10 +5902,10 @@ thinkingEnabled) {
|
|
|
5699
5902
|
});
|
|
5700
5903
|
}
|
|
5701
5904
|
// Surface the Fast mode toggle only when the current model supports it. The
|
|
5702
|
-
// option
|
|
5703
|
-
//
|
|
5905
|
+
// option renders as a native boolean toggle for Clients that opted in, and a
|
|
5906
|
+
// two-value select otherwise.
|
|
5704
5907
|
if (fastMode?.supported) {
|
|
5705
|
-
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
|
|
5908
|
+
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
|
|
5706
5909
|
}
|
|
5707
5910
|
// Surface the Thinking toggle whenever the caller supplies its display
|
|
5708
5911
|
// state. Unlike Fast mode it is model-independent — no `supported` gate —
|
|
@@ -6166,7 +6369,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
|
|
|
6166
6369
|
resumedContextWindow,
|
|
6167
6370
|
};
|
|
6168
6371
|
}
|
|
6169
|
-
function getAvailableSlashCommands(commands
|
|
6372
|
+
function getAvailableSlashCommands(commands,
|
|
6373
|
+
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
|
6374
|
+
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
|
6375
|
+
// the static list. Raw CLI names, matched before the MCP rename.
|
|
6376
|
+
terminalCommands) {
|
|
6170
6377
|
const UNSUPPORTED_COMMANDS = [
|
|
6171
6378
|
"clear",
|
|
6172
6379
|
"cost",
|
|
@@ -6178,6 +6385,7 @@ function getAvailableSlashCommands(commands) {
|
|
|
6178
6385
|
"todos",
|
|
6179
6386
|
];
|
|
6180
6387
|
const advertised = commands
|
|
6388
|
+
.filter((command) => !terminalCommands?.includes(command.name))
|
|
6181
6389
|
.map((command) => {
|
|
6182
6390
|
const input = command.argumentHint
|
|
6183
6391
|
? {
|
|
@@ -6361,13 +6569,13 @@ function isTaskTool(toolName) {
|
|
|
6361
6569
|
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
|
6362
6570
|
* resolved explicitly at tool_result time. */
|
|
6363
6571
|
function shouldEmitToolCall(toolName) {
|
|
6364
|
-
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
6572
|
+
return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
|
|
6365
6573
|
}
|
|
6366
6574
|
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
|
|
6367
6575
|
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
6368
6576
|
* command preview, while still giving clients access to Claude's concise
|
|
6369
6577
|
* human-readable title. */
|
|
6370
|
-
function claudeCodeMetaFromToolUse(toolUse) {
|
|
6578
|
+
function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
6371
6579
|
const description = toolUse.name === "Bash" &&
|
|
6372
6580
|
toolUse.input !== null &&
|
|
6373
6581
|
typeof toolUse.input === "object" &&
|
|
@@ -6375,11 +6583,53 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
6375
6583
|
typeof toolUse.input.description === "string"
|
|
6376
6584
|
? toolUse.input.description
|
|
6377
6585
|
: undefined;
|
|
6586
|
+
const skillName = toolUse.name === "Skill"
|
|
6587
|
+
? toolUse.input?.skill
|
|
6588
|
+
: undefined;
|
|
6589
|
+
const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
|
|
6378
6590
|
return {
|
|
6379
6591
|
toolName: toolUse.name,
|
|
6380
6592
|
...(description ? { title: description } : {}),
|
|
6381
6593
|
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
|
|
6594
|
+
...(skillName ? { skill: skillName } : {}),
|
|
6595
|
+
...(skillPath ? { skillPath } : {}),
|
|
6596
|
+
};
|
|
6597
|
+
}
|
|
6598
|
+
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6599
|
+
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6600
|
+
/**
|
|
6601
|
+
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
|
6602
|
+
*
|
|
6603
|
+
* The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
|
|
6604
|
+
* actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
|
|
6605
|
+
* for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
|
|
6606
|
+
* directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
|
|
6607
|
+
* on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
|
|
6608
|
+
*/
|
|
6609
|
+
function resolveSkillPath(skillName, cwd) {
|
|
6610
|
+
if (!cwd) {
|
|
6611
|
+
return undefined;
|
|
6612
|
+
}
|
|
6613
|
+
const colon = skillName.indexOf(":");
|
|
6614
|
+
const scope = colon < 0 ? undefined : skillName.slice(0, colon);
|
|
6615
|
+
const name = colon < 0 ? skillName : skillName.slice(colon + 1);
|
|
6616
|
+
if (!name) {
|
|
6617
|
+
return undefined;
|
|
6618
|
+
}
|
|
6619
|
+
const candidates = [];
|
|
6620
|
+
const addCandidates = (base) => {
|
|
6621
|
+
for (const container of SKILL_CONTAINER_DIRS) {
|
|
6622
|
+
candidates.push(path.join(base, container, name, "SKILL.md"));
|
|
6623
|
+
}
|
|
6382
6624
|
};
|
|
6625
|
+
if (scope) {
|
|
6626
|
+
// A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
|
|
6627
|
+
addCandidates(path.join(cwd, scope));
|
|
6628
|
+
candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
|
|
6629
|
+
}
|
|
6630
|
+
addCandidates(cwd);
|
|
6631
|
+
addCandidates(os.homedir());
|
|
6632
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
6383
6633
|
}
|
|
6384
6634
|
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
6385
6635
|
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
@@ -6391,7 +6641,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
6391
6641
|
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
6392
6642
|
if (refine) {
|
|
6393
6643
|
return {
|
|
6394
|
-
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
|
|
6644
|
+
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
|
|
6395
6645
|
toolCallId: toolUse.id,
|
|
6396
6646
|
sessionUpdate: "tool_call_update",
|
|
6397
6647
|
rawInput,
|
|
@@ -6400,7 +6650,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
6400
6650
|
}
|
|
6401
6651
|
return {
|
|
6402
6652
|
_meta: {
|
|
6403
|
-
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
|
6653
|
+
claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
|
|
6404
6654
|
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
6405
6655
|
? { terminal_info: { terminal_id: toolUse.id } }
|
|
6406
6656
|
: {}),
|
|
@@ -6426,7 +6676,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6426
6676
|
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
6427
6677
|
return {
|
|
6428
6678
|
_meta: {
|
|
6429
|
-
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
|
6679
|
+
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
|
|
6430
6680
|
},
|
|
6431
6681
|
toolCallId: toolUse.id,
|
|
6432
6682
|
sessionUpdate: "tool_call_update",
|
|
@@ -6436,33 +6686,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6436
6686
|
...(locations ? { locations } : {}),
|
|
6437
6687
|
};
|
|
6438
6688
|
}
|
|
6439
|
-
/** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
|
|
6440
|
-
* wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
|
|
6441
|
-
* by-tool_use_id lookup. Each entry explains why an is_error tool_result
|
|
6442
|
-
* carries harness prose instead of the tool's own output — "user-rejected",
|
|
6443
|
-
* "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
|
|
6444
|
-
* on the wire ahead of schema updates, so no enum check). Malformed entries
|
|
6445
|
-
* are skipped rather than failing the message. */
|
|
6446
|
-
function parseToolResultMeta(raw) {
|
|
6447
|
-
if (!Array.isArray(raw)) {
|
|
6448
|
-
return undefined;
|
|
6449
|
-
}
|
|
6450
|
-
let byToolUseId;
|
|
6451
|
-
for (const entry of raw) {
|
|
6452
|
-
if (typeof entry !== "object" || entry === null) {
|
|
6453
|
-
continue;
|
|
6454
|
-
}
|
|
6455
|
-
const { id, non_execution_kind, user_feedback } = entry;
|
|
6456
|
-
if (typeof id !== "string" || typeof non_execution_kind !== "string") {
|
|
6457
|
-
continue;
|
|
6458
|
-
}
|
|
6459
|
-
(byToolUseId ??= new Map()).set(id, {
|
|
6460
|
-
nonExecutionKind: non_execution_kind,
|
|
6461
|
-
...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
|
|
6462
|
-
});
|
|
6463
|
-
}
|
|
6464
|
-
return byToolUseId;
|
|
6465
|
-
}
|
|
6466
6689
|
/**
|
|
6467
6690
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
6468
6691
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -6472,7 +6695,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6472
6695
|
const registerHooks = options?.registerHooks !== false;
|
|
6473
6696
|
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
6474
6697
|
if (typeof content === "string") {
|
|
6475
|
-
if (content.length === 0) {
|
|
6698
|
+
if (content.length === 0 || containsFileChangeAuditMarker(content)) {
|
|
6476
6699
|
return [];
|
|
6477
6700
|
}
|
|
6478
6701
|
const update = {
|
|
@@ -6506,6 +6729,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6506
6729
|
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
|
6507
6730
|
// messages need no single-block guard.
|
|
6508
6731
|
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
|
6732
|
+
// A report-phase assistant message may contain a short text preface and the
|
|
6733
|
+
// internal tool call in the same content array. Hide the whole message, not
|
|
6734
|
+
// only the tool block, so it stays absent on session replay as well as live.
|
|
6735
|
+
const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
|
|
6736
|
+
chunk.type === "server_tool_use" ||
|
|
6737
|
+
chunk.type === "mcp_tool_use") &&
|
|
6738
|
+
isFileChangeAuditTool(chunk.name));
|
|
6509
6739
|
const output = [];
|
|
6510
6740
|
// Only handle the first chunk for streaming; extend as needed for batching
|
|
6511
6741
|
for (const chunk of content) {
|
|
@@ -6513,7 +6743,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6513
6743
|
switch (chunk.type) {
|
|
6514
6744
|
case "text":
|
|
6515
6745
|
case "text_delta": {
|
|
6516
|
-
if (chunk.text
|
|
6746
|
+
if (chunk.text &&
|
|
6747
|
+
!containsFileChangeAuditToolUse &&
|
|
6748
|
+
!containsFileChangeAuditMarker(chunk.text)) {
|
|
6517
6749
|
update = {
|
|
6518
6750
|
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6519
6751
|
content: {
|
|
@@ -6525,21 +6757,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6525
6757
|
break;
|
|
6526
6758
|
}
|
|
6527
6759
|
case "image":
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6760
|
+
if (!containsFileChangeAuditToolUse)
|
|
6761
|
+
update = {
|
|
6762
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6763
|
+
content: {
|
|
6764
|
+
type: "image",
|
|
6765
|
+
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
|
6766
|
+
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
|
6767
|
+
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
|
6768
|
+
},
|
|
6769
|
+
};
|
|
6537
6770
|
break;
|
|
6538
6771
|
case "thinking":
|
|
6539
6772
|
case "thinking_delta": {
|
|
6540
6773
|
// Recent models default `thinking.display` to "omitted", which streams
|
|
6541
6774
|
// signature-only thinking blocks whose text is empty.
|
|
6542
|
-
if (chunk.thinking) {
|
|
6775
|
+
if (chunk.thinking && !containsFileChangeAuditToolUse) {
|
|
6543
6776
|
update = {
|
|
6544
6777
|
sessionUpdate: "agent_thought_chunk",
|
|
6545
6778
|
content: {
|
|
@@ -6555,7 +6788,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6555
6788
|
case "mcp_tool_use": {
|
|
6556
6789
|
const alreadyCached = chunk.id in toolUseCache;
|
|
6557
6790
|
toolUseCache[chunk.id] = chunk;
|
|
6558
|
-
if (chunk.name
|
|
6791
|
+
if (isFileChangeAuditTool(chunk.name)) {
|
|
6792
|
+
// Wrapper-owned audit protocol: never surface or register generic
|
|
6793
|
+
// PostToolUse callbacks for the internal tool.
|
|
6794
|
+
}
|
|
6795
|
+
else if (chunk.name === "TodoWrite") {
|
|
6559
6796
|
// @ts-expect-error - sometimes input is empty object or undefined
|
|
6560
6797
|
if (Array.isArray(chunk.input?.todos)) {
|
|
6561
6798
|
update = {
|
|
@@ -6679,6 +6916,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6679
6916
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
6680
6917
|
break;
|
|
6681
6918
|
}
|
|
6919
|
+
if (isFileChangeAuditTool(toolUse.name)) {
|
|
6920
|
+
delete toolUseCache[chunk.tool_use_id];
|
|
6921
|
+
break;
|
|
6922
|
+
}
|
|
6682
6923
|
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
|
6683
6924
|
// suppressed (Task*) tool as a real tool_call so the request referenced
|
|
6684
6925
|
// a tool call the client knows about (see `ensureToolCallEmitted`,
|
|
@@ -6709,20 +6950,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6709
6950
|
}
|
|
6710
6951
|
if (isTaskTool(toolUse.name)) {
|
|
6711
6952
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
6712
|
-
// TaskCreate / TaskUpdate mutate the accumulated task list
|
|
6713
|
-
//
|
|
6714
|
-
//
|
|
6715
|
-
//
|
|
6953
|
+
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
|
6954
|
+
// reconciles it from the SDK's authoritative snapshot, which repairs
|
|
6955
|
+
// resumed or compacted sessions whose creating calls are no longer in
|
|
6956
|
+
// replay history. TaskGet is read-only and remains suppressed. Plan
|
|
6957
|
+
// updates always carry the full accumulated snapshot, mirroring the
|
|
6958
|
+
// legacy TodoWrite behavior.
|
|
6716
6959
|
const isError = "is_error" in chunk && chunk.is_error;
|
|
6960
|
+
let shouldEmitTaskPlan = false;
|
|
6717
6961
|
if (!isError) {
|
|
6718
6962
|
if (toolUse.name === "TaskCreate") {
|
|
6719
|
-
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
|
|
6963
|
+
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
|
|
6964
|
+
shouldEmitTaskPlan = true;
|
|
6720
6965
|
}
|
|
6721
6966
|
else if (toolUse.name === "TaskUpdate") {
|
|
6722
|
-
|
|
6967
|
+
const input = toolUse.input;
|
|
6968
|
+
const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
|
6969
|
+
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
|
6970
|
+
// Older CLI transcripts have no structured output, so retain the
|
|
6971
|
+
// input-based fallback. When an output is available, only apply a
|
|
6972
|
+
// confirmed update for the same task.
|
|
6973
|
+
if (!output || (output.success && output.taskId === input?.taskId)) {
|
|
6974
|
+
applyTaskUpdate(taskState, input);
|
|
6975
|
+
shouldEmitTaskPlan = true;
|
|
6976
|
+
}
|
|
6977
|
+
}
|
|
6978
|
+
else if (toolUse.name === "TaskList") {
|
|
6979
|
+
const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
|
6980
|
+
if (output) {
|
|
6981
|
+
applyTaskList(taskState, output);
|
|
6982
|
+
shouldEmitTaskPlan = true;
|
|
6983
|
+
}
|
|
6723
6984
|
}
|
|
6724
6985
|
}
|
|
6725
|
-
if (
|
|
6986
|
+
if (shouldEmitTaskPlan) {
|
|
6726
6987
|
update = {
|
|
6727
6988
|
sessionUpdate: "plan",
|
|
6728
6989
|
entries: taskStateToPlanEntries(taskState),
|
|
@@ -6762,7 +7023,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6762
7023
|
toolCallId: chunk.tool_use_id,
|
|
6763
7024
|
sessionUpdate: "tool_call_update",
|
|
6764
7025
|
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
6765
|
-
|
|
7026
|
+
// terminal_output already carried the exact bytes in the preceding
|
|
7027
|
+
// update. Repeating them as rawOutput wastes bandwidth and lets a
|
|
7028
|
+
// client accidentally render the same output twice.
|
|
7029
|
+
...(toolMeta?.terminal_output
|
|
7030
|
+
? {}
|
|
7031
|
+
: { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
|
|
6766
7032
|
...toolUpdate,
|
|
6767
7033
|
};
|
|
6768
7034
|
}
|
|
@@ -6783,7 +7049,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6783
7049
|
case "compaction":
|
|
6784
7050
|
case "compaction_delta":
|
|
6785
7051
|
case "advisor_tool_result":
|
|
6786
|
-
case "mid_conv_system":
|
|
6787
7052
|
case "fallback":
|
|
6788
7053
|
break;
|
|
6789
7054
|
default:
|
|
@@ -6940,7 +7205,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
|
|
|
6940
7205
|
signal.removeEventListener("abort", onAbort);
|
|
6941
7206
|
}
|
|
6942
7207
|
}
|
|
6943
|
-
export function runAcp() {
|
|
7208
|
+
export function runAcp(logger) {
|
|
6944
7209
|
const input = nodeToWebWritable(process.stdout);
|
|
6945
7210
|
const output = nodeToWebReadable(process.stdin);
|
|
6946
7211
|
const stream = ndJsonStream(input, output);
|
|
@@ -6973,7 +7238,7 @@ export function runAcp() {
|
|
|
6973
7238
|
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
6974
7239
|
.onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
|
|
6975
7240
|
.connect(stream);
|
|
6976
|
-
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
7241
|
+
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
|
6977
7242
|
return { connection, agent };
|
|
6978
7243
|
}
|
|
6979
7244
|
function commonPrefixLength(a, b) {
|
|
@@ -7068,6 +7333,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
|
|
|
7068
7333
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
7069
7334
|
"ANTHROPIC_API_KEY",
|
|
7070
7335
|
"ANTHROPIC_AUTH_TOKEN",
|
|
7336
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
7071
7337
|
];
|
|
7072
7338
|
/** Stable identifier for the LLM backend a session's query is created against,
|
|
7073
7339
|
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|