@lucascouts/claude-agent-tui 0.5.2 → 0.7.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/NOTICE +1 -1
- package/README.md +1 -1
- package/dist/acp-agent.d.ts +249 -21
- package/dist/acp-agent.js +573 -73
- package/dist/agent-catalog.d.ts +95 -0
- package/dist/agent-catalog.js +287 -0
- package/dist/ansi-mirror.d.ts +0 -1
- package/dist/besteffort.d.ts +0 -1
- package/dist/billing/entrypoint-guard.d.ts +0 -1
- package/dist/claude-path.d.ts +0 -1
- package/dist/claude-path.js +6 -0
- package/dist/command-catalog.d.ts +84 -0
- package/dist/command-catalog.js +339 -0
- package/dist/diff-enriched-reader.d.ts +0 -1
- package/dist/diff-source.d.ts +0 -1
- package/dist/drift-checks.d.ts +0 -1
- package/dist/end-of-turn.d.ts +6 -1
- package/dist/end-of-turn.js +8 -1
- package/dist/engine-lifecycle.d.ts +66 -2
- package/dist/engine-lifecycle.js +43 -4
- package/dist/engine-pty.d.ts +70 -3
- package/dist/engine-pty.js +80 -6
- package/dist/engine-watcher.d.ts +0 -1
- package/dist/engine.d.ts +0 -1
- package/dist/event-switch.d.ts +0 -1
- package/dist/gate/port.d.ts +0 -1
- package/dist/gate/settings-writer.d.ts +14 -1
- package/dist/gate/settings-writer.js +49 -0
- package/dist/image-input.d.ts +30 -0
- package/dist/image-input.js +79 -0
- package/dist/image-vision-smoke.d.ts +51 -0
- package/dist/image-vision-smoke.js +111 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +6 -0
- package/dist/jsonl.d.ts +0 -1
- package/dist/lib.d.ts +0 -1
- package/dist/linearize.d.ts +1 -2
- package/dist/linearize.js +1 -1
- package/dist/live-diff-env.d.ts +0 -1
- package/dist/live-subagent-env.d.ts +0 -1
- package/dist/mcp-config-writer.d.ts +60 -0
- package/dist/mcp-config-writer.js +172 -0
- package/dist/model-catalog.d.ts +68 -3
- package/dist/model-catalog.js +123 -13
- package/dist/permissions/allow-inject.d.ts +0 -1
- package/dist/permissions/deny.d.ts +12 -1
- package/dist/permissions/deny.js +18 -0
- package/dist/permissions/elicitation-bridge.d.ts +71 -0
- package/dist/permissions/elicitation-bridge.js +146 -0
- package/dist/permissions/gate-wiring.d.ts +23 -3
- package/dist/permissions/gate-wiring.js +123 -1
- package/dist/permissions/hook-server.d.ts +11 -3
- package/dist/permissions/hook-server.js +10 -1
- package/dist/permissions/permission-mode.d.ts +0 -1
- package/dist/permissions/request-permission.d.ts +0 -1
- package/dist/settings.d.ts +0 -1
- package/dist/settings.js +9 -0
- package/dist/stop-reason-map.d.ts +0 -1
- package/dist/subagent-gate.d.ts +0 -1
- package/dist/subagent-source.d.ts +0 -1
- package/dist/subagent-watcher.d.ts +0 -1
- package/dist/tools.d.ts +0 -1
- package/dist/tools.js +5 -1
- package/dist/usage-env.d.ts +0 -1
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -0
- package/dist/utils.d.ts +0 -1
- package/dist/zed-register.d.ts +0 -1
- package/package.json +12 -9
- package/dist/acp-agent.d.ts.map +0 -1
- package/dist/ansi-mirror.d.ts.map +0 -1
- package/dist/besteffort.d.ts.map +0 -1
- package/dist/billing/entrypoint-guard.d.ts.map +0 -1
- package/dist/claude-path.d.ts.map +0 -1
- package/dist/diff-enriched-reader.d.ts.map +0 -1
- package/dist/diff-source.d.ts.map +0 -1
- package/dist/drift-checks.d.ts.map +0 -1
- package/dist/end-of-turn.d.ts.map +0 -1
- package/dist/engine-lifecycle.d.ts.map +0 -1
- package/dist/engine-pty.d.ts.map +0 -1
- package/dist/engine-watcher.d.ts.map +0 -1
- package/dist/engine.d.ts.map +0 -1
- package/dist/event-switch.d.ts.map +0 -1
- package/dist/gate/port.d.ts.map +0 -1
- package/dist/gate/settings-writer.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonl.d.ts.map +0 -1
- package/dist/lib.d.ts.map +0 -1
- package/dist/linearize.d.ts.map +0 -1
- package/dist/live-diff-env.d.ts.map +0 -1
- package/dist/live-subagent-env.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/permissions/allow-inject.d.ts.map +0 -1
- package/dist/permissions/deny.d.ts.map +0 -1
- package/dist/permissions/gate-wiring.d.ts.map +0 -1
- package/dist/permissions/hook-server.d.ts.map +0 -1
- package/dist/permissions/permission-mode.d.ts.map +0 -1
- package/dist/permissions/request-permission.d.ts.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/stop-reason-map.d.ts.map +0 -1
- package/dist/subagent-gate.d.ts.map +0 -1
- package/dist/subagent-source.d.ts.map +0 -1
- package/dist/subagent-watcher.d.ts.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/usage-env.d.ts.map +0 -1
- package/dist/usage.d.ts.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- package/dist/zed-register.d.ts.map +0 -1
package/dist/acp-agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
-
import { deleteSession, listSessions, } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { deleteSession, getSessionInfo, listSessions, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
@@ -24,8 +24,23 @@ import { guardEvent } from "./billing/entrypoint-guard.js";
|
|
|
24
24
|
import { usageUpdatesFor } from "./usage.js";
|
|
25
25
|
import { createTurnResolver } from "./end-of-turn.js";
|
|
26
26
|
import { sendPrompt } from "./engine-pty.js";
|
|
27
|
-
import { MODEL_CATALOG, DEFAULT_MODEL_INFO } from "./model-catalog.js";
|
|
27
|
+
import { MODEL_CATALOG, MODEL_CONTEXT_WINDOWS, MODEL_ID_CONTEXT_WINDOWS, modelSelectorDescription, DEFAULT_MODEL_INFO, ULTRACODE_EFFORT, ULTRACODE_EFFORT_LEVEL, ULTRACODE_EFFORT_LABEL, } from "./model-catalog.js";
|
|
28
|
+
// Story 060 (R2.2/R2.3/R3.2) — the declarative spawn-time complement to the live ultracode keyword:
|
|
29
|
+
// toggle the {ultracode,ultracodeKeywordTrigger} keys in the gate's per-session scratch settings file
|
|
30
|
+
// (preserving the hook + every other key). Lives in the gate's settings-writer so it reuses durableWrite.
|
|
31
|
+
import { applyUltracodeSettings } from "./gate/settings-writer.js";
|
|
32
|
+
import { discoverAgents } from "./agent-catalog.js";
|
|
33
|
+
// Story 063 (R1) — OFFLINE disk discovery of the `available_commands` set (custom slash-commands +
|
|
34
|
+
// skills + enabled-plugin surfaces + built-ins), keyed on the session cwd. Populates the
|
|
35
|
+
// `available_commands_update` the session emits at creation in place of the old unconditional `[]`.
|
|
36
|
+
import { discoverCommands } from "./command-catalog.js";
|
|
28
37
|
import { setupSessionGate } from "./permissions/gate-wiring.js";
|
|
38
|
+
// Story 057 / Task 2.3 — MCP scratch-file lifecycle (translate ACP servers → claude `--mcp-config`
|
|
39
|
+
// JSON, durable 0600 write, idempotent teardown removal). Mirrors the gate's settings-scratch
|
|
40
|
+
// lifecycle: written BEFORE spawn, threaded as a flag, removed on failure + teardown — with the added
|
|
41
|
+
// re-spawn regeneration (R2.4). The module never logs the scratch contents/path-with-secrets (R2.3).
|
|
42
|
+
import { translateMcpServers, writeMcpScratch, removeMcpScratch } from "./mcp-config-writer.js";
|
|
43
|
+
import { materializeImage, cleanupMaterializedImages } from "./image-input.js";
|
|
29
44
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
30
45
|
const MAX_TITLE_LENGTH = 256;
|
|
31
46
|
function sanitizeTitle(text) {
|
|
@@ -39,7 +54,7 @@ function sanitizeTitle(text) {
|
|
|
39
54
|
}
|
|
40
55
|
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
|
41
56
|
}
|
|
42
|
-
const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
57
|
+
export const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
43
58
|
/**
|
|
44
59
|
* No-op {@link IPty} stub for the Degrau-1 replay-only load path: there is no live `claude` process,
|
|
45
60
|
* but the session record's `pty` field is typed `IPty`. Every method is inert — teardown's `kill()`
|
|
@@ -98,6 +113,14 @@ export async function defaultStartEngine(args) {
|
|
|
98
113
|
// SAME sessionId carrying its mode/effort flags through the resume argv (buildResumeArgv).
|
|
99
114
|
permissionMode: args.permissionMode,
|
|
100
115
|
effortLevel: args.effortLevel,
|
|
116
|
+
// Story 056 (R3.2): an agent-selecting re-spawn carries the persona through too (--agent).
|
|
117
|
+
agent: args.agent,
|
|
118
|
+
// Story 057 (R1.3/R3.1): re-thread the resolved additional-directory list so an in-place
|
|
119
|
+
// re-spawn keeps the SAME `--add-dir` scope (this resume call also serves respawnSession).
|
|
120
|
+
additionalDirectories: args.additionalDirectories,
|
|
121
|
+
// Story 057 (R2.2/R2.4): re-thread the CURRENT MCP scratch path so the re-spawned `claude`
|
|
122
|
+
// carries `--mcp-config "<file>"`; respawnSession regenerates the scratch before this call.
|
|
123
|
+
mcpConfigFile: args.mcpConfigFile,
|
|
101
124
|
});
|
|
102
125
|
if (args.inPlaceRespawn) {
|
|
103
126
|
// === SEAM(046 R3.4 LIVE FIX): DEFER discovery for an in-place re-spawn ======================
|
|
@@ -169,13 +192,26 @@ export async function defaultStartEngine(args) {
|
|
|
169
192
|
baseEnv: args.baseEnv,
|
|
170
193
|
sessions: args.sessions,
|
|
171
194
|
spawn: args.spawn,
|
|
195
|
+
// Story 056 v4: a FRESH in-place re-spawn (a pre-interaction selector change) reuses the session's
|
|
196
|
+
// existing id; a normal createSession passes none here (inPlaceRespawn absent) → fresh randomUUID.
|
|
197
|
+
sessionId: args.inPlaceRespawn ? args.sessionId : undefined,
|
|
172
198
|
// Story 046 (R3.2/R2.2): the seeded permission mode + effort → `--permission-mode`/`--effort` on
|
|
173
199
|
// the fresh spawn (non-"default" only; "default"/undefined keep the byte-for-byte pre-046 argv).
|
|
174
200
|
permissionMode: args.permissionMode,
|
|
175
201
|
effortLevel: args.effortLevel,
|
|
202
|
+
// Story 056 (R3.2): the agent-selecting re-spawn's persona → `--agent "<name>"` (non-"default").
|
|
203
|
+
agent: args.agent,
|
|
176
204
|
// Story 034 (§9): the per-session gate scratch settings, already on disk — claude reads them at
|
|
177
205
|
// startup, so the hook gates the FIRST tool call (blocker c). Absent → ungated (pre-034) spawn.
|
|
178
206
|
settingsFile: args.settingsFile,
|
|
207
|
+
// Story 057 (R1.3/R3.1): the resolved additional-directory list → one `--add-dir "<dir>"` per
|
|
208
|
+
// safe entry on the fresh interactive spawn (always-on; engine sanitizes per-dir). Empty/absent
|
|
209
|
+
// keeps the pre-057 argv byte-for-byte.
|
|
210
|
+
additionalDirectories: args.additionalDirectories,
|
|
211
|
+
// Story 057 (R2.2): the fork's MCP scratch path → `--mcp-config "<file>"` (never `--strict`,
|
|
212
|
+
// R2.2 merge); written on disk BEFORE this call so claude reads it at startup. Absent (no MCP
|
|
213
|
+
// servers declared) keeps the pre-057 argv byte-for-byte.
|
|
214
|
+
mcpConfigFile: args.mcpConfigFile,
|
|
179
215
|
});
|
|
180
216
|
// Hand the engine the cancellation handle for the background poll. STORE-ONLY here — the
|
|
181
217
|
// cleanup→`.abort()` wiring (so tearing a never-interacted session down cancels this dangling poll)
|
|
@@ -228,13 +264,31 @@ export async function defaultStartEngine(args) {
|
|
|
228
264
|
cwd: args.cwd,
|
|
229
265
|
};
|
|
230
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Story 057 (R1.3/R3.1): resolve the session's additional-directory list from the request — the
|
|
269
|
+
* top-level `additionalDirectories` (the ACP field), else the legacy `_meta.additionalRoots`, else
|
|
270
|
+
* `[]`. ALWAYS-ON (no `FORK_*` opt-in gate). Both the fingerprint and the spawn must resolve through
|
|
271
|
+
* THIS single helper so the stored fingerprint and the recomputed one agree for identical inputs
|
|
272
|
+
* (the compare path in {@link getOrCreateSession} vs the store path in {@link createSession}).
|
|
273
|
+
*/
|
|
274
|
+
function resolveAdditionalDirs(p) {
|
|
275
|
+
// `_meta` is the loose ACP index-signature bag (`{ [k]: unknown } | null`); the additionalRoots
|
|
276
|
+
// fallback lives at the `NewSessionMeta` top level, so narrow through that shape (the same cast the
|
|
277
|
+
// newSession handler uses for `_meta.claudeCode.options`) rather than introducing `any`.
|
|
278
|
+
const roots = p._meta?.additionalRoots;
|
|
279
|
+
return p.additionalDirectories ?? roots ?? [];
|
|
280
|
+
}
|
|
231
281
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
232
282
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
233
283
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
234
|
-
* so that ordering differences don't trigger unnecessary recreations.
|
|
284
|
+
* so that ordering differences don't trigger unnecessary recreations.
|
|
285
|
+
* Story 057 (R1.3): the resolved additional-directory set is folded in (SORTED, so input order is
|
|
286
|
+
* irrelevant) — a changed `--add-dir` set therefore changes the fingerprint and forces a re-spawn,
|
|
287
|
+
* while a reordered-but-equal set does not. */
|
|
235
288
|
function computeSessionFingerprint(params) {
|
|
236
289
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
237
|
-
|
|
290
|
+
const dirs = [...resolveAdditionalDirs(params)].sort();
|
|
291
|
+
return JSON.stringify({ cwd: params.cwd, mcpServers: servers, additionalDirectories: dirs });
|
|
238
292
|
}
|
|
239
293
|
// === SEAM(012/023): the engine binary is resolved from the user's PATH. After the 023 rewrite,
|
|
240
294
|
// createSession no longer passes the SDK `pathToClaudeCodeExecutable`; the PTY engine (story 013)
|
|
@@ -482,6 +536,14 @@ function deriveSubagentLabel(messages, parentId) {
|
|
|
482
536
|
export class ClaudeAcpAgent {
|
|
483
537
|
constructor(client, logger, engine = createStubEngine(), deps = {}) {
|
|
484
538
|
this.backgroundTerminals = {};
|
|
539
|
+
/**
|
|
540
|
+
* Story 065 (R1/R3) — did the client advertise `clientCapabilities.elicitation.form`
|
|
541
|
+
* at initialize? Presence-based (a present `form` may legitimately be an empty `{}`,
|
|
542
|
+
* so this is derived with `!= null`, NOT property truthiness). The 065 gate (task 3.1)
|
|
543
|
+
* reads this to decide relay-via-elicitation (R1) vs the story-064 deny fallback (R3).
|
|
544
|
+
* Defaults `false` so a client that never advertised elicitation falls back safely.
|
|
545
|
+
*/
|
|
546
|
+
this.clientSupportsElicitationForm = false;
|
|
485
547
|
/** Live PTY-engine registry shared with the per-session engines (story 014 cleanup map). */
|
|
486
548
|
this.engines = new Map();
|
|
487
549
|
this.sessions = {};
|
|
@@ -518,9 +580,30 @@ export class ClaudeAcpAgent {
|
|
|
518
580
|
// OFF at this seam so directly-constructed test agents spin no gate unless they opt in.
|
|
519
581
|
this.gateEnabled = deps.gate ?? false;
|
|
520
582
|
this.gateOptions = deps.gateOptions;
|
|
583
|
+
// Story 056 (R3.2): main-thread agent-persona discovery — defaults to the glob-only
|
|
584
|
+
// discoverAgents; tests inject an in-memory fake so the `agent` surface is hermetic.
|
|
585
|
+
this.discoverAgents = deps.discoverAgents ?? discoverAgents;
|
|
586
|
+
// Story 063 (R1/R1.1): offline `available_commands` discovery — defaults to the disk-only
|
|
587
|
+
// discoverCommands; tests inject an in-memory fake so the surface is hermetic (no real ~/.claude read).
|
|
588
|
+
this.discoverCommands = deps.discoverCommands ?? discoverCommands;
|
|
589
|
+
// Story 056 (#812): end-of-turn session_info_update title source — defaults to the pure SDK
|
|
590
|
+
// getSessionInfo; tests inject an in-memory fake so the push is hermetic (no real ~/.claude read).
|
|
591
|
+
this.getSessionInfo = deps.getSessionInfo ?? getSessionInfo;
|
|
521
592
|
}
|
|
522
593
|
async initialize(request) {
|
|
523
594
|
this.clientCapabilities = request.clientCapabilities;
|
|
595
|
+
// Story 065 (R1/R3): capability negotiation — a present (non-null) `elicitation.form`
|
|
596
|
+
// means the client supports form elicitation. Presence-based detection is deliberate: both
|
|
597
|
+
// `undefined` and `null` are unsupported, and an empty `{}` `form` IS supported (the UNSTABLE
|
|
598
|
+
// ElicitationFormCapabilities type carries only an optional `_meta`, so a present `form` is
|
|
599
|
+
// legitimately `{}` — detection MUST be presence-based, not truthiness). Written as explicit
|
|
600
|
+
// `!== undefined && !== null` (not `!= null`) to satisfy the eqeqeq lint rule.
|
|
601
|
+
const elicitationForm = request.clientCapabilities?.elicitation?.form;
|
|
602
|
+
this.clientSupportsElicitationForm = elicitationForm !== undefined && elicitationForm !== null;
|
|
603
|
+
// Story 065 (Task 6.1 live-probe): make the negotiated capability observable in the Zed logs so
|
|
604
|
+
// the in-Zed verdict (form rendered vs. gated-dormant behind the 064 deny) is deterministic. Goes
|
|
605
|
+
// to STDERR via logger.error — NEVER stdout, which carries the ACP ndJson stream.
|
|
606
|
+
this.logger.error(`[065] clientCapabilities.elicitation.form advertised: ${this.clientSupportsElicitationForm}`);
|
|
524
607
|
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
|
|
525
608
|
// Only offered when the client advertises `auth._meta.gateway` capability.
|
|
526
609
|
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
|
|
@@ -724,6 +807,19 @@ export class ClaudeAcpAgent {
|
|
|
724
807
|
}
|
|
725
808
|
throw new Error("Method not implemented.");
|
|
726
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* ACP `logout` (acp-sdk 1.0.0, acp.d.ts:1646). Under the PTY engine the bridge
|
|
812
|
+
* authenticates lazily and only tracks an in-memory `gatewayAuthRequest`; the
|
|
813
|
+
* interactive `claude` TUI owns the on-disk credential lifecycle. So `logout`
|
|
814
|
+
* here drops the in-memory auth intent and re-offers a clean handshake on the
|
|
815
|
+
* next `initialize()` (authMethods are recomputed there, unconditioned by this
|
|
816
|
+
* field). It does NOT read/write/delete `~/.claude` (billing seam — story 062
|
|
817
|
+
* R2) and never bridges `/logout` to the PTY (R3). Idempotent with no prior
|
|
818
|
+
* authenticate() (R4); active sessions are untouched (R6).
|
|
819
|
+
*/
|
|
820
|
+
async logout(_params) {
|
|
821
|
+
this.gatewayAuthRequest = undefined;
|
|
822
|
+
}
|
|
727
823
|
async prompt(params) {
|
|
728
824
|
const sessionRecord = this.sessions[params.sessionId];
|
|
729
825
|
if (!sessionRecord) {
|
|
@@ -746,7 +842,19 @@ export class ClaudeAcpAgent {
|
|
|
746
842
|
// SYNCHRONOUSLY before the `await promise`, so the PTY write is committed (and the detector is
|
|
747
843
|
// reachable by the live pump and the cancel path) the instant the turn begins.
|
|
748
844
|
// (1) Assemble the PTY text payload from the ContentBlock[] (Task 1 rewrote this to return text).
|
|
749
|
-
|
|
845
|
+
// Story 058 (R2.1/R2.2): pass a fresh sink so every image promptToClaude materializes is recorded
|
|
846
|
+
// on the session BEFORE the prompt is sent — so the turn-settle + teardown cleanups can unlink it
|
|
847
|
+
// and leave no orphan temp image.
|
|
848
|
+
const turnTempImagePaths = [];
|
|
849
|
+
// Bind the sink to the session BEFORE calling promptToClaude (same array by reference): even if a
|
|
850
|
+
// future promptToClaude were to throw mid-materialize, the teardown cleanup still reaches the paths.
|
|
851
|
+
sessionRecord.turnTempImagePaths = turnTempImagePaths;
|
|
852
|
+
const payload = promptToClaude(params, this.logger, turnTempImagePaths);
|
|
853
|
+
// Story 060 (R2.2) — while ultracode is active, prefix the OUTGOING prompt with the `ultracode`
|
|
854
|
+
// keyword (the binary's per-turn Workflow opt-in). This is the LIVE activation that needs no
|
|
855
|
+
// re-spawn (Option A) and works pre- AND post-first-interaction; the scratch settings keys are the
|
|
856
|
+
// declarative spawn-time complement. NEVER emitted as a `/effort` value (R1.2).
|
|
857
|
+
const outgoing = sessionRecord.ultracodeActive ? `${ULTRACODE_EFFORT} ${payload}` : payload;
|
|
750
858
|
// (2) Register the turn with the story-024 resolver: the detector that the live pump feeds, and
|
|
751
859
|
// the awaitable that settles ONCE with { stopReason: mapStopReason(...) } on the terminal
|
|
752
860
|
// boundary (or rejects on the watchdog). One shared `schedule` drives sendPrompt + the resolver.
|
|
@@ -754,6 +862,10 @@ export class ClaudeAcpAgent {
|
|
|
754
862
|
schedule: this.schedule,
|
|
755
863
|
sessionId: params.sessionId,
|
|
756
864
|
logger: this.logger,
|
|
865
|
+
// Story 056 (#812): on a REAL end-of-turn boundary (never cancel, never watchdog), push the
|
|
866
|
+
// sanitized session title via session_info_update. `void` = fire-and-forget — the async method
|
|
867
|
+
// is never awaited, so it cannot delay the `return await promise` below (R5.1).
|
|
868
|
+
onTurnResolved: () => void this.emitSessionTitleUpdate(params.sessionId),
|
|
757
869
|
});
|
|
758
870
|
sessionRecord.turnDetector = detector;
|
|
759
871
|
sessionRecord.turnCancel = cancel;
|
|
@@ -762,12 +874,15 @@ export class ClaudeAcpAgent {
|
|
|
762
874
|
// On a PTY-write failure, reject the pending prompt via the throw — markCancelled clears the
|
|
763
875
|
// detector's Δt + watchdog timers so nothing is left hung — rather than swallowing the error.
|
|
764
876
|
try {
|
|
765
|
-
sendPrompt(sessionRecord.pty,
|
|
877
|
+
sendPrompt(sessionRecord.pty, outgoing, this.schedule);
|
|
766
878
|
}
|
|
767
879
|
catch (e) {
|
|
768
880
|
detector.markCancelled();
|
|
769
881
|
sessionRecord.turnDetector = undefined;
|
|
770
882
|
sessionRecord.turnCancel = undefined;
|
|
883
|
+
// Story 058 (R2.1): the turn never reached the model — drop its materialized temp images now.
|
|
884
|
+
cleanupMaterializedImages(sessionRecord.turnTempImagePaths);
|
|
885
|
+
sessionRecord.turnTempImagePaths = undefined;
|
|
771
886
|
throw e;
|
|
772
887
|
}
|
|
773
888
|
// (4) Resolve ONLY via the detector's terminal boundary. The pump feeds raw JSONL messages to
|
|
@@ -778,6 +893,10 @@ export class ClaudeAcpAgent {
|
|
|
778
893
|
finally {
|
|
779
894
|
sessionRecord.turnDetector = undefined;
|
|
780
895
|
sessionRecord.turnCancel = undefined;
|
|
896
|
+
// Story 058 (R2.1/R2.2): the turn is over — resolved OR cancelled (both settle this same
|
|
897
|
+
// promise, per the comment below) — so unlink the temp images materialized for it. No orphans.
|
|
898
|
+
cleanupMaterializedImages(sessionRecord.turnTempImagePaths);
|
|
899
|
+
sessionRecord.turnTempImagePaths = undefined;
|
|
781
900
|
// Story 044 (R2.3): the turn is over — resolved OR cancelled, both settle this same promise —
|
|
782
901
|
// so the in-turn sub-agent watcher dies with it (covers turn-resolve AND markCancelled paths).
|
|
783
902
|
sessionRecord.subagentWatcher?.stop();
|
|
@@ -786,6 +905,36 @@ export class ClaudeAcpAgent {
|
|
|
786
905
|
this.flushPendingControlInjections(sessionRecord);
|
|
787
906
|
}
|
|
788
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* Story 056 (#812) — push the sanitized session title to the client via `session_info_update`,
|
|
910
|
+
* fired (fire-and-forget) by the story-024 end-of-turn boundary ONLY (never on cancel/watchdog,
|
|
911
|
+
* via {@link TurnResolverOptions.onTurnResolved}). DEDUPED against {@link Session.lastEmittedTitle}
|
|
912
|
+
* so an unchanged title is not re-emitted, and silent when `getSessionInfo` finds no transcript /
|
|
913
|
+
* the title is empty. Every error is swallowed and logged — this MUST NEVER reject the turn (it is
|
|
914
|
+
* never awaited in `prompt()`), and a slow/never-resolving reader cannot delay the PromptResponse.
|
|
915
|
+
*/
|
|
916
|
+
async emitSessionTitleUpdate(sessionId) {
|
|
917
|
+
const session = this.sessions[sessionId];
|
|
918
|
+
if (!session)
|
|
919
|
+
return;
|
|
920
|
+
try {
|
|
921
|
+
const info = await this.getSessionInfo(sessionId, { dir: session.cwd });
|
|
922
|
+
if (!info)
|
|
923
|
+
return; // no transcript / not found → nothing to push
|
|
924
|
+
const title = sanitizeTitle(info.summary);
|
|
925
|
+
if (!title || title === session.lastEmittedTitle)
|
|
926
|
+
return; // dedup + never push empty
|
|
927
|
+
session.lastEmittedTitle = title;
|
|
928
|
+
await this.client.sessionUpdate({
|
|
929
|
+
sessionId,
|
|
930
|
+
update: { sessionUpdate: "session_info_update", title },
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
// Swallow — never reject the turn (this method is never awaited from prompt()).
|
|
935
|
+
this.logger.error("[acp-agent] session title push (#812) failed:", err);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
789
938
|
async cancel(params) {
|
|
790
939
|
const sessionRecord = this.sessions[params.sessionId];
|
|
791
940
|
if (!sessionRecord) {
|
|
@@ -832,6 +981,12 @@ export class ClaudeAcpAgent {
|
|
|
832
981
|
return;
|
|
833
982
|
}
|
|
834
983
|
await this.cancel({ sessionId });
|
|
984
|
+
// Story 058 (R2.1): idempotent backstop — unlink any temp images that survived a turn whose
|
|
985
|
+
// prompt-finally never ran (e.g. a session torn down between turns or before the finally fired).
|
|
986
|
+
// The cancel above may have already cleared them; cleanupMaterializedImages never throws on a
|
|
987
|
+
// gone file, so a double-cleanup is a safe no-op.
|
|
988
|
+
cleanupMaterializedImages(session.turnTempImagePaths);
|
|
989
|
+
session.turnTempImagePaths = undefined;
|
|
835
990
|
// Story 044 (R2.3): stop the sub-agent watcher on teardown — idempotent with the prompt-finally stop.
|
|
836
991
|
session.subagentWatcher?.stop();
|
|
837
992
|
session.subagentWatcher = undefined;
|
|
@@ -857,11 +1012,19 @@ export class ClaudeAcpAgent {
|
|
|
857
1012
|
if (session.gate) {
|
|
858
1013
|
await session.gate.teardown();
|
|
859
1014
|
}
|
|
1015
|
+
// Story 057 (R2.3): remove the MCP scratch on teardown so no secret-bearing file (auth headers/
|
|
1016
|
+
// env) is orphaned. Idempotent + never throws + never logs the contents; a no-op when the session
|
|
1017
|
+
// declared no MCP servers (mcpConfigFile undefined) or after a re-spawn already swapped/removed it.
|
|
1018
|
+
if (session.mcpConfigFile) {
|
|
1019
|
+
await removeMcpScratch(session.mcpConfigFile);
|
|
1020
|
+
}
|
|
860
1021
|
this.engines.delete(sessionId);
|
|
861
1022
|
delete this.sessions[sessionId];
|
|
862
1023
|
}
|
|
863
1024
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
864
1025
|
async dispose() {
|
|
1026
|
+
// Drop the in-memory auth intent on teardown (story 062 R7) — same clear as logout().
|
|
1027
|
+
this.gatewayAuthRequest = undefined;
|
|
865
1028
|
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
|
866
1029
|
}
|
|
867
1030
|
async closeSession(params) {
|
|
@@ -871,7 +1034,7 @@ export class ClaudeAcpAgent {
|
|
|
871
1034
|
await this.teardownSession(params.sessionId);
|
|
872
1035
|
return {};
|
|
873
1036
|
}
|
|
874
|
-
async
|
|
1037
|
+
async deleteSession(params) {
|
|
875
1038
|
// Tear down any active in-memory state first so the on-disk file isn't
|
|
876
1039
|
// recreated by an outstanding query writing to it.
|
|
877
1040
|
if (this.sessions[params.sessionId]) {
|
|
@@ -931,17 +1094,29 @@ export class ClaudeAcpAgent {
|
|
|
931
1094
|
const resolvedValue = validValue.value;
|
|
932
1095
|
if (params.configId === "mode") {
|
|
933
1096
|
await this.applySessionMode(params.sessionId, resolvedValue);
|
|
1097
|
+
// Story 056 v4 (OPTIMISTIC notify) — for a CYCLABLE mode, push `current_mode_update` to the panel
|
|
1098
|
+
// BEFORE driving the (slower) closed-loop Shift+Tab cycle, so the selector reflects the choice
|
|
1099
|
+
// INSTANTLY instead of waiting out the cycle. Safe: the permission gate reads the mode from the
|
|
1100
|
+
// transcript (tail-as-truth), NOT this notification, and a cyclable drive never re-spawns (no R3.7
|
|
1101
|
+
// rollback to honor). dontAsk/bypass (a re-spawn that CAN fail) keep the notify AFTER the drive so
|
|
1102
|
+
// a failed switch does not leave the panel showing a mode that never applied.
|
|
1103
|
+
const cyclable = CYCLABLE_MODES.has(resolvedValue);
|
|
1104
|
+
if (cyclable) {
|
|
1105
|
+
await this.client.sessionUpdate({
|
|
1106
|
+
sessionId: params.sessionId,
|
|
1107
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: resolvedValue },
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
934
1110
|
// Bug A fix (Story 046 R3) — Zed sends mode changes via set_config_option(configId:"mode"), so
|
|
935
1111
|
// the DRIVE must happen HERE too. This path used to only validate (read-only), leaving claude
|
|
936
1112
|
// stuck on its spawn mode — the live permission mode never changed (bypass/acceptEdits no-op'd).
|
|
937
1113
|
await this.driveModeIntoTui(params.sessionId, session, resolvedValue);
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
sessionUpdate: "current_mode_update",
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
1114
|
+
if (!cyclable) {
|
|
1115
|
+
await this.client.sessionUpdate({
|
|
1116
|
+
sessionId: params.sessionId,
|
|
1117
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: resolvedValue },
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
945
1120
|
}
|
|
946
1121
|
// === SEAM(023→046) Group 1: the dropped SDK `query.setModel` is replaced by a PTY side-channel.
|
|
947
1122
|
// `/model <alias>` is a LOCAL TUI command (no assistant turn, no stop_reason) — inject it as a
|
|
@@ -953,10 +1128,19 @@ export class ClaudeAcpAgent {
|
|
|
953
1128
|
this.applyModelSwitch(session, resolvedValue);
|
|
954
1129
|
}
|
|
955
1130
|
else if (params.configId === "effort") {
|
|
956
|
-
// Story
|
|
957
|
-
// re-
|
|
958
|
-
// applyConfigOptionValue below
|
|
959
|
-
|
|
1131
|
+
// Story 056 v4: effort is now a LIVE `/effort <level>` injection (claude 2.1.195 has the command),
|
|
1132
|
+
// mirroring /model — no re-spawn, works before the first interaction. Mid-turn it defers; it never
|
|
1133
|
+
// throws, so applyConfigOptionValue below always commits the new currentValue (optimistic, like
|
|
1134
|
+
// /model). The flag path stays only to seed/preserve effort across mode/agent re-spawns.
|
|
1135
|
+
// Story 060 (R2/R3.2): route through applyEffortSelection so the `ultracode` sentinel is
|
|
1136
|
+
// special-cased (activate keyword + scratch keys + /effort xhigh) while real levels deselect it.
|
|
1137
|
+
await this.applyEffortSelection(session, resolvedValue);
|
|
1138
|
+
}
|
|
1139
|
+
else if (params.configId === "agent") {
|
|
1140
|
+
// Story 056 (R3.3/R3.4): the agent persona has no live mid-session path either — apply it by an
|
|
1141
|
+
// in-place re-spawn carrying `--agent` (mirrors effort). On throw, applyConfigOptionValue below is
|
|
1142
|
+
// skipped so the prior currentValue stays unchanged (R3.7-style failure path).
|
|
1143
|
+
await this.applyAgentChange(params.sessionId, session, resolvedValue);
|
|
960
1144
|
}
|
|
961
1145
|
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
|
|
962
1146
|
return { configOptions: session.configOptions };
|
|
@@ -1066,6 +1250,12 @@ export class ClaudeAcpAgent {
|
|
|
1066
1250
|
session.pendingModelInjection = undefined;
|
|
1067
1251
|
this.injectModelCommand(session, alias);
|
|
1068
1252
|
}
|
|
1253
|
+
// Story 056 v4 — flush a deferred effort `/effort <level>` injection too (last-write-wins).
|
|
1254
|
+
const effort = session.pendingEffortInjection;
|
|
1255
|
+
if (effort !== undefined) {
|
|
1256
|
+
session.pendingEffortInjection = undefined;
|
|
1257
|
+
this.injectEffortCommand(session, effort);
|
|
1258
|
+
}
|
|
1069
1259
|
}
|
|
1070
1260
|
/**
|
|
1071
1261
|
* Story 046 (R3.3, design §6b) — drive the TUI to `target` with closed-loop raw Shift+Tab. Writes
|
|
@@ -1126,7 +1316,9 @@ export class ClaudeAcpAgent {
|
|
|
1126
1316
|
* Order is load-bearing for R3.7: re-spawn FIRST, and swap in + tear down the old PTY ONLY once the new
|
|
1127
1317
|
* one is live — so a failed re-spawn leaves the prior PTY/currentValue intact (never
|
|
1128
1318
|
* torn-down-without-replacement). Re-spawn runs only while idle, so the old PTY has no pending turn to
|
|
1129
|
-
* double-resolve. The `respawning` latch defers concurrent selector changes (R3.8).
|
|
1319
|
+
* double-resolve. The `respawning` latch defers concurrent selector changes (R3.8). Re-spawning for
|
|
1320
|
+
* ONE selector preserves the OTHER two (mode / effort / agent — Story 056 added agent) by reading
|
|
1321
|
+
* their current values, so the resume argv always carries all three flags.
|
|
1130
1322
|
*/
|
|
1131
1323
|
async respawnSession(sessionId, session, change) {
|
|
1132
1324
|
// Story 046 (R3.4 LIVE FIX guard): a re-spawn reattaches via `claude --resume <id>`, which needs the
|
|
@@ -1136,33 +1328,75 @@ export class ClaudeAcpAgent {
|
|
|
1136
1328
|
// once. The user/Zed retries after the first prompt; a boot-time default_config_options dontAsk/
|
|
1137
1329
|
// bypass therefore stays at the fresh spawn's mode (use a fresh-spawn --permission-mode seed for
|
|
1138
1330
|
// start-in-bypass, a documented follow-up). The OTHER selector's currentValue is left unchanged.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1331
|
+
// Story 056 v4 — before the first interaction there is NO transcript to `--resume`, so the re-spawn
|
|
1332
|
+
// is FRESH (reusing the SAME sessionId — LIVE-VERIFIED: claude accepts a reused --session-id once the
|
|
1333
|
+
// prior PTY exits). After the first interaction, `--resume` reattaches the transcript (R3.4). This is
|
|
1334
|
+
// what lets the agent/effort/mode selectors apply BEFORE the first prompt — the live gap the user hit
|
|
1335
|
+
// (previously this threw and the selector silently reverted). NOTE: effort no longer reaches here
|
|
1336
|
+
// (it is a live `/effort` inject now); the fresh path serves agent and the dontAsk/bypass modes.
|
|
1337
|
+
const fresh = !session.interacted;
|
|
1142
1338
|
session.respawning = true;
|
|
1143
1339
|
try {
|
|
1144
1340
|
const oldEngine = session.engine;
|
|
1145
|
-
// Preserve the OTHER
|
|
1146
|
-
// reset the
|
|
1341
|
+
// Preserve the OTHER selectors' current values so re-spawning for one (mode, effort, OR agent)
|
|
1342
|
+
// does not reset the others — the argv carries all three flags. There are three selectors now
|
|
1343
|
+
// (Story 056 added agent): a mode re-spawn keeps effort+agent, an agent re-spawn keeps mode+effort.
|
|
1147
1344
|
const permissionMode = change.permissionMode ?? session.modes.currentModeId;
|
|
1148
|
-
|
|
1345
|
+
// Story 060 (R1.2 fix): the preserved currentEffort can be the `ultracode` SENTINEL (it is the
|
|
1346
|
+
// committed configOption value while active). It is NOT a real `--effort` enum value — the binary
|
|
1347
|
+
// rejects `--effort ultracode` (story-060 probe), so a mode/agent re-spawn-while-active would
|
|
1348
|
+
// silently degrade effort to default. Map the sentinel to its real component (xhigh) at THIS spawn
|
|
1349
|
+
// seam (it feeds BOTH buildClaudeCmd and buildResumeArgv); the scratch `ultracode:true` already
|
|
1350
|
+
// carries the orchestration activation declaratively at spawn.
|
|
1351
|
+
const preservedEffort = change.effortLevel ?? this.currentEffort(session);
|
|
1352
|
+
const effortLevel = preservedEffort === ULTRACODE_EFFORT ? ULTRACODE_EFFORT_LEVEL : preservedEffort;
|
|
1353
|
+
const agent = change.agent ?? this.currentAgent(session);
|
|
1354
|
+
// Story 057 (R2.4): REGENERATE the MCP scratch so the re-spawned `claude` reads the CURRENT MCP
|
|
1355
|
+
// config at startup (its `--mcp-config` is bound only at spawn). Re-translate from the stored raw
|
|
1356
|
+
// ACP servers (kept faithful to the original request). Write the NEW scratch BEFORE removing the
|
|
1357
|
+
// OLD so a write failure leaves the prior scratch intact (the still-running old PTY already read
|
|
1358
|
+
// its config at startup, so removing the old file does not disturb it). removeMcpScratch is
|
|
1359
|
+
// idempotent + never throws + never logs the contents (R2.3).
|
|
1360
|
+
if (session.mcpServers && session.mcpServers.length > 0) {
|
|
1361
|
+
const old = session.mcpConfigFile;
|
|
1362
|
+
session.mcpConfigFile = await writeMcpScratch(translateMcpServers(session.mcpServers));
|
|
1363
|
+
if (old)
|
|
1364
|
+
await removeMcpScratch(old);
|
|
1365
|
+
}
|
|
1366
|
+
// FRESH re-spawn (pre-interaction): retire the old fresh PTY FIRST to free the reused sessionId —
|
|
1367
|
+
// there is no transcript to preserve. RESUME re-spawn: keep the R3.7 order (bring the new PTY up
|
|
1368
|
+
// BEFORE retiring the old, so a failed re-spawn leaves the prior PTY + currentValue intact).
|
|
1369
|
+
if (fresh) {
|
|
1370
|
+
oldEngine?.cleanup();
|
|
1371
|
+
oldEngine?.kill();
|
|
1372
|
+
}
|
|
1149
1373
|
// Re-spawn through the SAME startEngine seam createSession uses, reusing the sessionId so the
|
|
1150
|
-
// transcript is reattached (R3.4)
|
|
1374
|
+
// transcript is reattached on resume (R3.4) or freshly created on the pre-interaction path; the
|
|
1375
|
+
// flags flow into the flag-carrying spawn argv (fresh `buildClaudeCmd` or `buildResumeArgv`).
|
|
1151
1376
|
const started = await this.startEngine({
|
|
1152
1377
|
sessionId,
|
|
1153
1378
|
cwd: session.cwd,
|
|
1154
|
-
resume:
|
|
1155
|
-
// Story 046 (R3.4 LIVE FIX): an in-place re-spawn may run before the first interaction
|
|
1156
|
-
//
|
|
1379
|
+
resume: !fresh,
|
|
1380
|
+
// Story 046 (R3.4 LIVE FIX): an in-place re-spawn may run before the first interaction, so DEFER
|
|
1381
|
+
// discovery instead of the 2000ms fatal watchdog — see defaultStartEngine.
|
|
1157
1382
|
inPlaceRespawn: true,
|
|
1158
1383
|
permissionMode,
|
|
1159
1384
|
effortLevel,
|
|
1385
|
+
agent,
|
|
1386
|
+
// Story 057 (R1.3/R3.1): re-thread the SAME `--add-dir` scope into the re-spawn (sub-task 1.2
|
|
1387
|
+
// wired only the fresh createSession path; the in-place re-spawn must preserve it too).
|
|
1388
|
+
additionalDirectories: session.additionalDirectories,
|
|
1389
|
+
// Story 057 (R2.4): the freshly-regenerated MCP scratch path (see above) → `--mcp-config` on
|
|
1390
|
+
// the re-spawned `claude`, so it carries the current MCP config.
|
|
1391
|
+
mcpConfigFile: session.mcpConfigFile,
|
|
1160
1392
|
sessions: this.engines,
|
|
1161
1393
|
onEvent: (sid) => void this.pumpUpdates(sid),
|
|
1162
1394
|
});
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1395
|
+
if (!fresh) {
|
|
1396
|
+
// New PTY is live — only now retire the old one (idle ⇒ no pending turn to double-resolve).
|
|
1397
|
+
oldEngine?.cleanup();
|
|
1398
|
+
oldEngine?.kill();
|
|
1399
|
+
}
|
|
1166
1400
|
session.pty = started.pty;
|
|
1167
1401
|
session.engine = started.engine;
|
|
1168
1402
|
session.watcher = started.watcher;
|
|
@@ -1179,20 +1413,102 @@ export class ClaudeAcpAgent {
|
|
|
1179
1413
|
const opt = session.configOptions.find((o) => o.id === "effort");
|
|
1180
1414
|
return typeof opt?.currentValue === "string" ? opt.currentValue : undefined;
|
|
1181
1415
|
}
|
|
1416
|
+
/** Story 056 — the session's current agent configOption value (undefined when no agent option). */
|
|
1417
|
+
currentAgent(session) {
|
|
1418
|
+
const opt = session.configOptions.find((o) => o.id === "agent");
|
|
1419
|
+
return typeof opt?.currentValue === "string" ? opt.currentValue : undefined;
|
|
1420
|
+
}
|
|
1182
1421
|
/**
|
|
1183
|
-
* Story
|
|
1184
|
-
*
|
|
1185
|
-
* (
|
|
1186
|
-
*
|
|
1187
|
-
* the
|
|
1422
|
+
* Story 060 (R2/R3.2) — apply an effort-selector choice, special-casing the `ultracode` sentinel.
|
|
1423
|
+
*
|
|
1424
|
+
* Selecting `ultracode` (Option A — keyword + scratch, NO re-spawn): activate the session flag (which
|
|
1425
|
+
* makes {@link prompt} prefix the OUTGOING prompt with the `ultracode` keyword — the binary's per-turn
|
|
1426
|
+
* Workflow opt-in, the effective live mechanism), write the scratch ultracode keys via
|
|
1427
|
+
* {@link applyUltracodeSettings} (the declarative spawn-time complement), and set the effort to xhigh
|
|
1428
|
+
* through the SAME live `/effort` inject as every other level — NEVER `/effort ultracode` (R1.2). The
|
|
1429
|
+
* `already` guard suppresses a redundant `/effort xhigh` re-inject when ultracode is re-selected while
|
|
1430
|
+
* already active.
|
|
1431
|
+
*
|
|
1432
|
+
* Selecting a real level (or `default`) DEACTIVATES ultracode: clear the flag, remove the scratch keys,
|
|
1433
|
+
* then apply that level through {@link applyEffortChange} (whose own no-op guard handles a same-level
|
|
1434
|
+
* pick). `applyConfigOptionValue` (the caller, after this returns) commits the selector's currentValue,
|
|
1435
|
+
* which for `ultracode` correctly stays `"ultracode"` (the {@link buildConfigOptions} `includes` guard
|
|
1436
|
+
* keeps it valid across rebuilds).
|
|
1188
1437
|
*/
|
|
1189
|
-
async
|
|
1438
|
+
async applyEffortSelection(session, value) {
|
|
1439
|
+
if (value === ULTRACODE_EFFORT) {
|
|
1440
|
+
const already = session.ultracodeActive === true;
|
|
1441
|
+
session.ultracodeActive = true;
|
|
1442
|
+
// The gate's per-session SCRATCH settings file is the spawn's `--settings` target; on a live
|
|
1443
|
+
// Session it is reachable via `session.gate?.settingsPath` (the value createSession also threads
|
|
1444
|
+
// into StartEngineArgs.settingsFile). Absent on a no-gate / resume / replay session → keyword-only.
|
|
1445
|
+
const scratchPath = session.gate?.settingsPath;
|
|
1446
|
+
if (scratchPath) {
|
|
1447
|
+
// Declarative scratch keys for any future (re-)spawn (NOT a re-spawn trigger — Option A).
|
|
1448
|
+
await applyUltracodeSettings(scratchPath, true);
|
|
1449
|
+
}
|
|
1450
|
+
// Effort component is xhigh, applied via the live /effort inject — but only when not already active
|
|
1451
|
+
// (re-selecting ultracode must not re-inject `/effort xhigh`). applyEffortChange's own no-op guard
|
|
1452
|
+
// also short-circuits if xhigh already equals the current effort.
|
|
1453
|
+
if (!already) {
|
|
1454
|
+
this.applyEffortChange(session, ULTRACODE_EFFORT_LEVEL);
|
|
1455
|
+
}
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
// A real level (or `default`) was chosen → deactivate ultracode before applying it.
|
|
1459
|
+
if (session.ultracodeActive) {
|
|
1460
|
+
session.ultracodeActive = false;
|
|
1461
|
+
const scratchPath = session.gate?.settingsPath;
|
|
1462
|
+
if (scratchPath) {
|
|
1463
|
+
await applyUltracodeSettings(scratchPath, false);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
this.applyEffortChange(session, value);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Story 046 (R2.2) + Story 056 v4 — apply a reasoning-effort change LIVE via `/effort <level>`.
|
|
1470
|
+
* SUPERSEDES the 046 Probe-B re-spawn: `claude` 2.1.195 DOES have a live `/effort <level>` local TUI
|
|
1471
|
+
* command (LIVE-VERIFIED — "Set effort level to high…", applied inline, NO "Switch?" dialog unlike
|
|
1472
|
+
* /model). So effort now mirrors {@link applyModelSwitch}: a side-channel write, no re-spawn, no turn —
|
|
1473
|
+
* which means it ALSO works BEFORE the first interaction (the re-spawn's --resume idle-guard was why
|
|
1474
|
+
* effort silently failed pre-first-prompt). Mid-turn it defers (pendingEffortInjection) and flushes
|
|
1475
|
+
* when the turn settles. A no-op change applies nothing; effort stays preserved across mode/agent
|
|
1476
|
+
* re-spawns (currentEffort → --effort flag), so the spawn-flag path remains as the seed/preserve route.
|
|
1477
|
+
*/
|
|
1478
|
+
applyEffortChange(session, level) {
|
|
1190
1479
|
if (level === this.currentEffort(session))
|
|
1191
1480
|
return; // no value change → no-op
|
|
1481
|
+
if (session.turnDetector !== undefined) {
|
|
1482
|
+
// A turn is in flight — injecting mid-turn corrupts the PTY input. Defer (coalesce, mirrors /model).
|
|
1483
|
+
session.pendingEffortInjection = level;
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
this.injectEffortCommand(session, level);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Side-channel `/effort <level>` write — synchronous, resolves immediately (never a turn). Unlike
|
|
1490
|
+
* `/model`, `/effort` applies INLINE with no blocking "Switch?" dialog (LIVE-VERIFIED 2.1.195), so
|
|
1491
|
+
* sendPrompt's own submit `\r` is sufficient and NO confirm Enter is scheduled.
|
|
1492
|
+
*/
|
|
1493
|
+
injectEffortCommand(session, level) {
|
|
1494
|
+
sendPrompt(session.pty, `/effort ${level}`, (fn) => fn());
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Story 056 (R3.3/R3.4) — apply a main-thread agent-persona change. Like effort, the persona has no
|
|
1498
|
+
* live mid-session mechanism (`--agent "<name>"` is a spawn flag), so a change re-spawns in place
|
|
1499
|
+
* carrying the flag (mirroring {@link applyEffortChange}), idle-guarded, with the R3.7 failure path
|
|
1500
|
+
* and the R3.8 latch. A no-op change (same persona, with the "default" sentinel as the no-persona
|
|
1501
|
+
* baseline) applies nothing. Throwing here leaves the caller's applyConfigOptionValue unrun, so the
|
|
1502
|
+
* prior currentValue is left unchanged on failure (R3.7). Optimistic-on-apply, like effort: there is
|
|
1503
|
+
* no transcript drift event for the agent persona, so it is NOT reconciled afterward (R4.3).
|
|
1504
|
+
*/
|
|
1505
|
+
async applyAgentChange(sessionId, session, agent) {
|
|
1506
|
+
if (agent === (this.currentAgent(session) ?? "default"))
|
|
1507
|
+
return; // no value change → no-op
|
|
1192
1508
|
if (session.turnDetector !== undefined || session.respawning) {
|
|
1193
|
-
throw new Error("Cannot change
|
|
1509
|
+
throw new Error("Cannot change agent while the session is busy (a turn is in flight or a re-spawn is underway); retry when idle");
|
|
1194
1510
|
}
|
|
1195
|
-
await this.respawnSession(sessionId, session, {
|
|
1511
|
+
await this.respawnSession(sessionId, session, { agent });
|
|
1196
1512
|
}
|
|
1197
1513
|
/**
|
|
1198
1514
|
* Story 046 (R4.1/R4.2/R4.3, design §8) — reconcile the `mode` configOption from the latest
|
|
@@ -1311,6 +1627,14 @@ export class ClaudeAcpAgent {
|
|
|
1311
1627
|
// in pumpUpdates, so the replay-only load never emitted it).
|
|
1312
1628
|
if (session && !session.usageDisabled) {
|
|
1313
1629
|
const carrier = turn.message.message ?? {};
|
|
1630
|
+
// Story 069 (R1) — refine the window from the turn's REAL model (the JSONL `model`), authoritatively
|
|
1631
|
+
// correcting the alias seed (e.g. default → claude-opus-4-8[1m] → 1M). A missing / non-string model or
|
|
1632
|
+
// an unknown id leaves the current value unchanged (R1.3 — never overwrite with null).
|
|
1633
|
+
const realModel = carrier.model;
|
|
1634
|
+
if (typeof realModel === "string" && realModel.length > 0) {
|
|
1635
|
+
session.contextWindowSize =
|
|
1636
|
+
inferContextWindowFromModelId(realModel) ?? session.contextWindowSize;
|
|
1637
|
+
}
|
|
1314
1638
|
for (const usageUpdate of usageUpdatesFor(carrier, {
|
|
1315
1639
|
usageUpdate: this.usageUpdate,
|
|
1316
1640
|
contextWindowSize: session.contextWindowSize,
|
|
@@ -1640,15 +1964,26 @@ export class ClaudeAcpAgent {
|
|
|
1640
1964
|
const session = this.sessions[sessionId];
|
|
1641
1965
|
if (!session)
|
|
1642
1966
|
return;
|
|
1643
|
-
// === SEAM(023) Group 1:
|
|
1644
|
-
// `query.supportedCommands()`
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1967
|
+
// === SEAM(023) Group 1: `available_commands_update`. Historically the SDK
|
|
1968
|
+
// `query.supportedCommands()` was dropped (slash commands are owned by the interactive TUI and are
|
|
1969
|
+
// not enumerable over the read-only JSONL path), so Degrau-1 emitted a static empty set.
|
|
1970
|
+
// Story 063 (R1/R1.1) now POPULATES this set OFFLINE from disk — `discoverCommands(session.cwd)`
|
|
1971
|
+
// scans the cwd/user `.claude/{commands,skills}`, the enabled-plugin surfaces, and the built-in
|
|
1972
|
+
// tier — instead of the unconditional `[]`. Discovery is SYNCHRONOUS but the 4 call-sites invoke
|
|
1973
|
+
// this method fire-and-forget (`setTimeout(0)`), so it never blocks session creation (R4).
|
|
1974
|
+
// Degrau 2 (030/032): PTY-backed control — surface the TUI's real live command set. ===
|
|
1975
|
+
let availableCommands;
|
|
1976
|
+
try {
|
|
1977
|
+
availableCommands = this.discoverCommands(session.cwd);
|
|
1978
|
+
}
|
|
1979
|
+
catch {
|
|
1980
|
+
availableCommands = []; // R4 — discovery must NEVER crash the session; degrade to []
|
|
1981
|
+
}
|
|
1647
1982
|
await this.client.sessionUpdate({
|
|
1648
1983
|
sessionId,
|
|
1649
1984
|
update: {
|
|
1650
1985
|
sessionUpdate: "available_commands_update",
|
|
1651
|
-
availableCommands
|
|
1986
|
+
availableCommands,
|
|
1652
1987
|
},
|
|
1653
1988
|
});
|
|
1654
1989
|
}
|
|
@@ -1708,7 +2043,12 @@ export class ClaudeAcpAgent {
|
|
|
1708
2043
|
// Rebuild config options since effort levels depend on the selected model
|
|
1709
2044
|
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1710
2045
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
1711
|
-
|
|
2046
|
+
// Story 056 (R3.2): preserve the `agent` option across a model switch — re-read its current
|
|
2047
|
+
// value and rebuild from the session's stored catalog (no re-glob). `session.agents ?? []`
|
|
2048
|
+
// keeps the option absent when none were discovered (the gate).
|
|
2049
|
+
const agentOpt = session.configOptions.find((o) => o.id === "agent");
|
|
2050
|
+
const currentAgent = typeof agentOpt?.currentValue === "string" ? agentOpt.currentValue : undefined;
|
|
2051
|
+
session.configOptions = buildConfigOptions(session.modes, value, session.modelInfos, currentEffort, session.agents ?? [], currentAgent);
|
|
1712
2052
|
// === SEAM(023) Group 1: the SDK effort sync (query.applyFlagSettings) after a model switch is
|
|
1713
2053
|
// dropped — configOptions already reflects the new effort locally.
|
|
1714
2054
|
// Degrau 2 (030/032): PTY-backed control. ===
|
|
@@ -1791,6 +2131,21 @@ export class ClaudeAcpAgent {
|
|
|
1791
2131
|
else {
|
|
1792
2132
|
requestedSessionId = randomUUID();
|
|
1793
2133
|
}
|
|
2134
|
+
// Story 057 (R1.3/R3.1): resolve the additional-directory list ONCE (always-on, no env gate) so
|
|
2135
|
+
// the SAME value threads to the spawn AND is stored on the session record (sub-task 2.3's
|
|
2136
|
+
// respawnSession re-threads it). The fingerprint resolves through the same helper — see below.
|
|
2137
|
+
const additionalDirs = resolveAdditionalDirs(params);
|
|
2138
|
+
// Story 057 (R2.2/R2.3, sub-task 2.3): WRITE the MCP scratch BEFORE the spawn when the session
|
|
2139
|
+
// declared ≥1 MCP server. Mirrors the gate's settings-scratch ordering (GATE_FINDINGS blocker c):
|
|
2140
|
+
// the file must be ON DISK before claude starts, because claude reads `--mcp-config` only at
|
|
2141
|
+
// startup. A replay-only load spawns nothing → no scratch. Always-on (no `FORK_*` gate, R3.1);
|
|
2142
|
+
// the ONLY condition is "mcpServers non-empty". The path is threaded into startEngine below and
|
|
2143
|
+
// stored on the session record (teardown removal + re-spawn regeneration). Awaited so a write
|
|
2144
|
+
// failure surfaces here (loudly) rather than racing the spawn. Never logged (R2.3).
|
|
2145
|
+
let mcpConfigFile;
|
|
2146
|
+
if (!creationOpts.replayOnly && params.mcpServers && params.mcpServers.length > 0) {
|
|
2147
|
+
mcpConfigFile = await writeMcpScratch(translateMcpServers(params.mcpServers));
|
|
2148
|
+
}
|
|
1794
2149
|
// SettingsManager is retained (kept methods read it; teardown disposes it). The PTY TUI reads
|
|
1795
2150
|
// the user's settings from disk itself — we no longer translate them into SDK `Options`.
|
|
1796
2151
|
const settingsManager = new SettingsManager(params.cwd, {
|
|
@@ -1819,6 +2174,11 @@ export class ClaudeAcpAgent {
|
|
|
1819
2174
|
gate = await setupSessionGate({
|
|
1820
2175
|
...this.gateOptions,
|
|
1821
2176
|
client: this.client,
|
|
2177
|
+
// Story 065 (R1/R3): negotiated in initialize() from clientCapabilities.elicitation.form. When
|
|
2178
|
+
// true the gate drives AskUserQuestion through a real ACP form elicitation; when false it keeps
|
|
2179
|
+
// the story-064 fail-closed deny-guard. this.client (AgentSideConnection) already satisfies the
|
|
2180
|
+
// broadened client type (it has unstable_createElicitation).
|
|
2181
|
+
clientSupportsElicitationForm: this.clientSupportsElicitationForm,
|
|
1822
2182
|
onWarn: (m) => this.logger.error(m),
|
|
1823
2183
|
});
|
|
1824
2184
|
}
|
|
@@ -1840,6 +2200,12 @@ export class ClaudeAcpAgent {
|
|
|
1840
2200
|
permissionMode: seededMode,
|
|
1841
2201
|
// Story 034: the gate's scratch settings file, consumed as `--settings "<file>"` (fresh path).
|
|
1842
2202
|
settingsFile: gate?.settingsPath,
|
|
2203
|
+
// Story 057 (R1.3/R3.1): the resolved additional-directory list → `--add-dir` on the spawn
|
|
2204
|
+
// (always-on; the engine sanitizes per-dir). Same list stored on the session record below.
|
|
2205
|
+
additionalDirectories: additionalDirs,
|
|
2206
|
+
// Story 057 (R2.2): the MCP scratch path (written above) → `--mcp-config "<file>"` on the
|
|
2207
|
+
// spawn. Same path stored on the session record below (teardown removal + re-spawn regen).
|
|
2208
|
+
mcpConfigFile,
|
|
1843
2209
|
});
|
|
1844
2210
|
}
|
|
1845
2211
|
catch (error) {
|
|
@@ -1850,6 +2216,12 @@ export class ClaudeAcpAgent {
|
|
|
1850
2216
|
// reaches the map, so teardownSession can never dispose it. Dispose it on this path.
|
|
1851
2217
|
settingsManager.dispose();
|
|
1852
2218
|
await gate?.teardown();
|
|
2219
|
+
// Story 057 (R2.3): a failed spawn must likewise leave NO MCP scratch behind (it was written
|
|
2220
|
+
// before startEngine). removeMcpScratch is idempotent + never throws, so it cannot mask the
|
|
2221
|
+
// original spawn error rethrown below.
|
|
2222
|
+
if (mcpConfigFile) {
|
|
2223
|
+
await removeMcpScratch(mcpConfigFile);
|
|
2224
|
+
}
|
|
1853
2225
|
if (creationOpts.resume && error instanceof Error) {
|
|
1854
2226
|
throw RequestError.resourceNotFound(requestedSessionId);
|
|
1855
2227
|
}
|
|
@@ -1896,7 +2268,13 @@ export class ClaudeAcpAgent {
|
|
|
1896
2268
|
currentModeId: seededMode,
|
|
1897
2269
|
availableModes,
|
|
1898
2270
|
};
|
|
1899
|
-
|
|
2271
|
+
// Story 056 (R3.2): discover the main-thread agent personas for THIS session's cwd (glob-only via
|
|
2272
|
+
// the injectable seam). When ≥1 is found, buildConfigOptions surfaces the 4th `agent` dropdown
|
|
2273
|
+
// (seeded "default" = no persona at fresh create); when none, the option is omitted. The catalog
|
|
2274
|
+
// is stored on the session record below so the model-change reconcile rebuilds it WITHOUT
|
|
2275
|
+
// re-globbing.
|
|
2276
|
+
const agents = this.discoverAgents(params.cwd);
|
|
2277
|
+
const configOptions = buildConfigOptions(modes, DEFAULT_MODEL_INFO.value, MODEL_CATALOG, settingsManager.getSettings().effortLevel, agents, undefined);
|
|
1900
2278
|
// Runtime cwd is read from inside the JSONL (story 015); fall back to the requested host cwd
|
|
1901
2279
|
// until the first transcript line carries `.cwd` (the seam may return cwd === undefined early).
|
|
1902
2280
|
const runtimeCwd = started.cwd ?? params.cwd;
|
|
@@ -1911,6 +2289,13 @@ export class ClaudeAcpAgent {
|
|
|
1911
2289
|
engine: started.engine,
|
|
1912
2290
|
cancelled: false,
|
|
1913
2291
|
cwd: runtimeCwd,
|
|
2292
|
+
// Story 057 (R1.3): the resolved additional-directory list, stored so sub-task 2.3's
|
|
2293
|
+
// respawnSession can re-thread the SAME `--add-dir` scope into the in-place re-spawn.
|
|
2294
|
+
additionalDirectories: additionalDirs,
|
|
2295
|
+
// Story 057 (R2.3/R2.4): the CURRENT MCP scratch path (for teardown removal + re-spawn regen)
|
|
2296
|
+
// and the RAW ACP server array (so respawnSession can re-translate + regenerate the scratch).
|
|
2297
|
+
mcpConfigFile,
|
|
2298
|
+
mcpServers: params.mcpServers,
|
|
1914
2299
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
1915
2300
|
settingsManager,
|
|
1916
2301
|
accumulatedUsage: {
|
|
@@ -1921,6 +2306,7 @@ export class ClaudeAcpAgent {
|
|
|
1921
2306
|
},
|
|
1922
2307
|
modes,
|
|
1923
2308
|
modelInfos: MODEL_CATALOG,
|
|
2309
|
+
agents,
|
|
1924
2310
|
configOptions,
|
|
1925
2311
|
contextWindowSize: inferContextWindowFromModel(DEFAULT_MODEL_INFO.value) ?? DEFAULT_CONTEXT_WINDOW,
|
|
1926
2312
|
taskState,
|
|
@@ -1981,7 +2367,7 @@ function buildAvailableModes(modelInfo) {
|
|
|
1981
2367
|
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
|
|
1982
2368
|
// `"default"` sentinel and `undefined` (effort option absent for the model) to
|
|
1983
2369
|
// `null` ensures any previously-applied flag is actually cleared.
|
|
1984
|
-
function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel) {
|
|
2370
|
+
function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel, agents = [], currentAgent) {
|
|
1985
2371
|
const options = [
|
|
1986
2372
|
{
|
|
1987
2373
|
id: "mode",
|
|
@@ -2006,7 +2392,9 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
|
|
|
2006
2392
|
options: modelInfos.map((m) => ({
|
|
2007
2393
|
value: m.value,
|
|
2008
2394
|
name: m.displayName,
|
|
2009
|
-
|
|
2395
|
+
// Story 072 — prepend the version/context label ("Opus 4.8 with 1M context · <tagline>"),
|
|
2396
|
+
// mirroring the live `/model` picker; bare tagline when no label (e.g. opusplan).
|
|
2397
|
+
description: modelSelectorDescription(m) || undefined,
|
|
2010
2398
|
})),
|
|
2011
2399
|
},
|
|
2012
2400
|
];
|
|
@@ -2025,8 +2413,13 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
|
|
|
2025
2413
|
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
|
2026
2414
|
.join(" "),
|
|
2027
2415
|
})),
|
|
2416
|
+
// Story 060 (R1.1) — the "ultracode" sentinel, LAST and after the five real levels. NOT a real
|
|
2417
|
+
// `--effort` value (claude rejects `--effort ultracode`); it maps to xhigh + orchestration (Task 3).
|
|
2418
|
+
{ value: ULTRACODE_EFFORT, name: ULTRACODE_EFFORT_LABEL },
|
|
2028
2419
|
];
|
|
2029
|
-
|
|
2420
|
+
// `ultracode` is a valid current value so a configOptions rebuild (e.g. after a re-spawn) does not
|
|
2421
|
+
// reset a selected ultracode back to "default". It stays OUT of supportedLevels (real --effort enum).
|
|
2422
|
+
const includes = (l) => l === "default" || l === ULTRACODE_EFFORT || supportedLevels.includes(l);
|
|
2030
2423
|
const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
|
2031
2424
|
options.push({
|
|
2032
2425
|
id: "effort",
|
|
@@ -2038,6 +2431,27 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
|
|
|
2038
2431
|
options: effortOptions,
|
|
2039
2432
|
});
|
|
2040
2433
|
}
|
|
2434
|
+
// Story 056 (R3.2) — the `agent` (main-thread persona) selector, mirroring the effort option but
|
|
2435
|
+
// with a "default" no-persona sentinel. GATED on `agents.length > 0` (upstream #794): when nothing
|
|
2436
|
+
// is discovered the option is OMITTED entirely. The "default" entry = no persona (the spawn layer
|
|
2437
|
+
// already drops the literal "default", exactly like --effort/--permission-mode). The current value
|
|
2438
|
+
// is validated against the discovered set and falls back to "default".
|
|
2439
|
+
if (agents.length > 0) {
|
|
2440
|
+
const agentValues = new Set(agents.map((a) => a.value));
|
|
2441
|
+
const validAgent = currentAgent && agentValues.has(currentAgent) ? currentAgent : "default";
|
|
2442
|
+
options.push({
|
|
2443
|
+
id: "agent",
|
|
2444
|
+
name: "Agent",
|
|
2445
|
+
description: "Main-thread agent persona",
|
|
2446
|
+
category: "model",
|
|
2447
|
+
type: "select",
|
|
2448
|
+
currentValue: validAgent,
|
|
2449
|
+
options: [
|
|
2450
|
+
{ value: "default", name: "Default" },
|
|
2451
|
+
...agents.map((a) => ({ value: a.value, name: a.displayName, description: a.description })),
|
|
2452
|
+
],
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2041
2455
|
return options;
|
|
2042
2456
|
}
|
|
2043
2457
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
@@ -2167,14 +2581,27 @@ function filePathFromUri(uri) {
|
|
|
2167
2581
|
* anything below the threshold — or large but path-less — is
|
|
2168
2582
|
* inlined directly so the context is not lost.
|
|
2169
2583
|
*
|
|
2170
|
-
*
|
|
2171
|
-
* emit
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2584
|
+
* - image → materialize the base64 to a uuid-named temp file (extension from mimeType)
|
|
2585
|
+
* and emit `@<temp-path>` (Story 058 / R1.1). Once at least one image is
|
|
2586
|
+
* materialized, a single Read-inducing directive is appended after the loop so
|
|
2587
|
+
* the TUI's Read tool fires and vision-encodes it (R1.2). Each temp path is
|
|
2588
|
+
* pushed into `materializedSink` (when provided) so the caller can clean it up.
|
|
2589
|
+
*
|
|
2590
|
+
* `resource` (blob) / `audio` blocks are SILENT no-ops here (R4.1): they emit no PTY bytes
|
|
2591
|
+
* and are NOT logged — they are expected-but-unsupported media in v1, not errors. An UNKNOWN
|
|
2592
|
+
* block `type` (the `default` branch) and any block whose mapping THROWS are treated as
|
|
2593
|
+
* malformed: skipped, recorded via the `logger`, and the remaining valid blocks still map —
|
|
2594
|
+
* one bad block never aborts the whole prompt (R1.3). A `materializeImage` failure is caught
|
|
2595
|
+
* by that same per-block isolation, so a broken image is skipped, never aborting the prompt.
|
|
2596
|
+
*
|
|
2597
|
+
* `materializedSink`, when passed, receives every materialized temp path (in order) so the
|
|
2598
|
+
* caller owns their lifecycle (cleanup is a later task). The return type stays `string`.
|
|
2175
2599
|
*/
|
|
2176
|
-
export function promptToClaude(prompt, logger = console) {
|
|
2600
|
+
export function promptToClaude(prompt, logger = console, materializedSink) {
|
|
2177
2601
|
const fragments = [];
|
|
2602
|
+
// Set once any image block is materialized, so exactly ONE Read-inducing directive is
|
|
2603
|
+
// appended after the loop regardless of how many images the prompt carries (R1.2).
|
|
2604
|
+
let materializedAnyImage = false;
|
|
2178
2605
|
for (const chunk of prompt.prompt) {
|
|
2179
2606
|
// R1.3: isolate every block. A malformed block — even one whose `type` getter
|
|
2180
2607
|
// throws — is SKIPPED and RECORDED, never allowed to abort the remaining blocks.
|
|
@@ -2224,9 +2651,25 @@ export function promptToClaude(prompt, logger = console) {
|
|
|
2224
2651
|
}
|
|
2225
2652
|
break;
|
|
2226
2653
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2654
|
+
case "image": {
|
|
2655
|
+
// R1.1: an ACP image carries base64 `data` + `mimeType` (NOT `source`/`media_type`).
|
|
2656
|
+
// Materialize it to a uuid-named temp file and reference it with `@<path>` so the TUI
|
|
2657
|
+
// re-reads (and vision-encodes) it — mirroring the `resource_link` @<path> idiom. A
|
|
2658
|
+
// single Read-inducing directive is appended AFTER the loop (R1.2). If materialize
|
|
2659
|
+
// throws, the surrounding per-block try/catch isolates it (R1.3): this image is skipped.
|
|
2660
|
+
//
|
|
2661
|
+
// R1.3 (shell-safety): the path is uuid-named + fork-controlled and the prompt body reaches
|
|
2662
|
+
// the PTY via bracketed-paste (engine-pty `sendPrompt` → `p.write`), NOT the `bash -lc` spawn
|
|
2663
|
+
// string — so no shell ever parses `@<path>` and the prompt has no injection surface. extFor
|
|
2664
|
+
// maps mimeType to a CLOSED extension set, so a hostile mimeType cannot reach the filename.
|
|
2665
|
+
const tempPath = materializeImage(chunk.data, chunk.mimeType);
|
|
2666
|
+
materializedSink?.push(tempPath);
|
|
2667
|
+
fragments.push(`@${tempPath}`);
|
|
2668
|
+
materializedAnyImage = true;
|
|
2669
|
+
break;
|
|
2670
|
+
}
|
|
2671
|
+
// audio → SILENT no-op (R4.1): expected-but-unsupported media in v1. It emits no PTY
|
|
2672
|
+
// bytes and is NOT logged (it is not an error).
|
|
2230
2673
|
case "audio":
|
|
2231
2674
|
break;
|
|
2232
2675
|
default:
|
|
@@ -2243,6 +2686,11 @@ export function promptToClaude(prompt, logger = console) {
|
|
|
2243
2686
|
continue;
|
|
2244
2687
|
}
|
|
2245
2688
|
}
|
|
2689
|
+
// R1.2: exactly one Read-inducing directive per prompt when ≥1 image was materialized, so the
|
|
2690
|
+
// TUI's Read tool fires on the @<path>(s) above and vision-encodes them (the proven 2.1.195 path).
|
|
2691
|
+
if (materializedAnyImage) {
|
|
2692
|
+
fragments.push("Read the attached image(s) above and use them to answer.");
|
|
2693
|
+
}
|
|
2246
2694
|
return fragments.filter((fragment) => fragment.length > 0).join(" ");
|
|
2247
2695
|
}
|
|
2248
2696
|
/**
|
|
@@ -2300,13 +2748,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2300
2748
|
break;
|
|
2301
2749
|
case "thinking":
|
|
2302
2750
|
case "thinking_delta":
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2751
|
+
// Story 056 (#793): a signature-only thinking block (thinking.display "omitted") carries empty
|
|
2752
|
+
// text — suppress the agent_thought_chunk rather than emit an empty one (update stays null → no
|
|
2753
|
+
// push at the `if (update)` guard). A non-empty thinking block emits exactly as before.
|
|
2754
|
+
if (chunk.thinking.length > 0) {
|
|
2755
|
+
update = {
|
|
2756
|
+
sessionUpdate: "agent_thought_chunk",
|
|
2757
|
+
content: {
|
|
2758
|
+
type: "text",
|
|
2759
|
+
text: chunk.thinking,
|
|
2760
|
+
},
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2310
2763
|
break;
|
|
2311
2764
|
case "tool_use":
|
|
2312
2765
|
case "server_tool_use":
|
|
@@ -2570,13 +3023,60 @@ export function runAcp(deps) {
|
|
|
2570
3023
|
}, stream);
|
|
2571
3024
|
return { connection, agent };
|
|
2572
3025
|
}
|
|
2573
|
-
/**
|
|
2574
|
-
*
|
|
2575
|
-
*
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2578
|
-
|
|
3026
|
+
/** Resolve a model alias's context window (the usage_update `size` denominator).
|
|
3027
|
+
* NOTE (story 068): there is NO `result.modelUsage` window to refresh from — the
|
|
3028
|
+
* JSONL `usage` carries only token counts; the window comes from static curation
|
|
3029
|
+
* (the Models API `max_input_tokens` is the real authority, which this fork does
|
|
3030
|
+
* not call), as detailed below.
|
|
3031
|
+
*
|
|
3032
|
+
* Story 068 (R1, R1.1, R1.2): consults the static {@link MODEL_CONTEXT_WINDOWS}
|
|
3033
|
+
* alias→window map FIRST (an exact catalog-`value` hit — `opus`=1M, `sonnet`=200K,
|
|
3034
|
+
* `sonnet[1m]`=1M, `haiku`=200K, `default`/`opusplan`=200K conservative). This
|
|
3035
|
+
* fixes `opus` having wrongly reported 200K. An alias absent from the map then
|
|
3036
|
+
* falls back to the legacy `\b1m\b` inference: Anthropic 1M-context variants
|
|
3037
|
+
* encode "1m" as a distinct token in the SDK model ID (e.g., "claude-opus-4-6-1m"),
|
|
3038
|
+
* which `\b1m\b` catches without also matching "10m" or embedded substrings.
|
|
3039
|
+
* `null` (fully unknown) is intentional — the two call sites apply
|
|
3040
|
+
* `?? DEFAULT_CONTEXT_WINDOW`. */
|
|
3041
|
+
export function inferContextWindowFromModel(model) {
|
|
3042
|
+
const mapped = MODEL_CONTEXT_WINDOWS[model];
|
|
3043
|
+
if (mapped !== undefined)
|
|
3044
|
+
return mapped; // exact alias hit (!== undefined, NOT truthiness)
|
|
2579
3045
|
if (/\b1m\b/i.test(model))
|
|
3046
|
+
return 1_000_000; // unknown alias that still encodes a 1m token
|
|
3047
|
+
return null; // caller applies ?? DEFAULT_CONTEXT_WINDOW
|
|
3048
|
+
}
|
|
3049
|
+
/** Story 069 (R1) — AUTHORITATIVE context window from a turn's REAL model ID (the JSONL `model`
|
|
3050
|
+
* field), used by the pump to refine the alias seed once the model is known. Exact-ID lookup first
|
|
3051
|
+
* (MODEL_ID_CONTEXT_WINDOWS), then a family+version heuristic for dated snapshots / future variants
|
|
3052
|
+
* (Opus is NOT uniform: 4.6 and earlier = 200K, 4.7+ = 1M; Sonnet 4.x = 200K but Sonnet 5+ = 1M;
|
|
3053
|
+
* haiku = 200K; fable = 1M — story 071), then a
|
|
3054
|
+
* `\b1m\b` suffix, then null (R1.3: a missing / non-string id never refines). */
|
|
3055
|
+
export function inferContextWindowFromModelId(id) {
|
|
3056
|
+
if (typeof id !== "string" || id.length === 0)
|
|
3057
|
+
return null;
|
|
3058
|
+
const exact = MODEL_ID_CONTEXT_WINDOWS[id];
|
|
3059
|
+
if (exact !== undefined)
|
|
3060
|
+
return exact;
|
|
3061
|
+
// An explicit long-context `[1m]`/`-1m` suffix wins over the family heuristic
|
|
3062
|
+
// (`claude-sonnet-…[1m]` = 1M, not 200K; `/model default` resolves to `claude-opus-4-8[1m]`).
|
|
3063
|
+
if (/\b1m\b/i.test(id))
|
|
3064
|
+
return 1_000_000;
|
|
3065
|
+
const opus = id.match(/claude-opus-(\d+)-(\d+)/);
|
|
3066
|
+
if (opus) {
|
|
3067
|
+
const major = Number(opus[1]);
|
|
3068
|
+
const minor = Number(opus[2]);
|
|
3069
|
+
return major > 4 || (major === 4 && minor >= 7) ? 1_000_000 : 200_000;
|
|
3070
|
+
}
|
|
3071
|
+
if (/claude-fable/.test(id))
|
|
2580
3072
|
return 1_000_000;
|
|
3073
|
+
// Sonnet is NOT uniform across generations (story 071): the subscription CLI serves Sonnet 4.x
|
|
3074
|
+
// at 200K but Sonnet 5+ natively at 1M (Sonnet 5 has no smaller context variant). Version-aware,
|
|
3075
|
+
// like the Opus 4-6 vs 4-7/4-8 split above; dated snapshots (`claude-sonnet-5-<date>`) match too.
|
|
3076
|
+
const sonnet = id.match(/claude-sonnet-(\d+)/);
|
|
3077
|
+
if (sonnet)
|
|
3078
|
+
return Number(sonnet[1]) >= 5 ? 1_000_000 : 200_000;
|
|
3079
|
+
if (/claude-haiku/.test(id))
|
|
3080
|
+
return 200_000;
|
|
2581
3081
|
return null;
|
|
2582
3082
|
}
|