@lucascouts/claude-agent-tui 0.5.2 → 0.6.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 +187 -13
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +444 -59
- package/dist/agent-catalog.d.ts +96 -0
- package/dist/agent-catalog.d.ts.map +1 -0
- package/dist/agent-catalog.js +287 -0
- package/dist/claude-path.d.ts.map +1 -1
- package/dist/claude-path.js +6 -0
- package/dist/end-of-turn.d.ts +6 -0
- package/dist/end-of-turn.d.ts.map +1 -1
- package/dist/end-of-turn.js +8 -1
- package/dist/engine-lifecycle.d.ts +66 -1
- package/dist/engine-lifecycle.d.ts.map +1 -1
- package/dist/engine-lifecycle.js +43 -4
- package/dist/engine-pty.d.ts +70 -2
- package/dist/engine-pty.d.ts.map +1 -1
- package/dist/engine-pty.js +80 -6
- package/dist/gate/settings-writer.d.ts +14 -0
- package/dist/gate/settings-writer.d.ts.map +1 -1
- package/dist/gate/settings-writer.js +49 -0
- package/dist/image-input.d.ts +31 -0
- package/dist/image-input.d.ts.map +1 -0
- package/dist/image-input.js +79 -0
- package/dist/image-vision-smoke.d.ts +52 -0
- package/dist/image-vision-smoke.d.ts.map +1 -0
- package/dist/image-vision-smoke.js +111 -0
- package/dist/index.js +6 -0
- package/dist/mcp-config-writer.d.ts +61 -0
- package/dist/mcp-config-writer.d.ts.map +1 -0
- package/dist/mcp-config-writer.js +172 -0
- package/dist/model-catalog.d.ts +29 -2
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +50 -10
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +9 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -1
- package/dist/usage.d.ts +3 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -0
- package/package.json +8 -8
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,19 @@ 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, 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";
|
|
28
33
|
import { setupSessionGate } from "./permissions/gate-wiring.js";
|
|
34
|
+
// Story 057 / Task 2.3 — MCP scratch-file lifecycle (translate ACP servers → claude `--mcp-config`
|
|
35
|
+
// JSON, durable 0600 write, idempotent teardown removal). Mirrors the gate's settings-scratch
|
|
36
|
+
// lifecycle: written BEFORE spawn, threaded as a flag, removed on failure + teardown — with the added
|
|
37
|
+
// re-spawn regeneration (R2.4). The module never logs the scratch contents/path-with-secrets (R2.3).
|
|
38
|
+
import { translateMcpServers, writeMcpScratch, removeMcpScratch } from "./mcp-config-writer.js";
|
|
39
|
+
import { materializeImage, cleanupMaterializedImages } from "./image-input.js";
|
|
29
40
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
30
41
|
const MAX_TITLE_LENGTH = 256;
|
|
31
42
|
function sanitizeTitle(text) {
|
|
@@ -98,6 +109,14 @@ export async function defaultStartEngine(args) {
|
|
|
98
109
|
// SAME sessionId carrying its mode/effort flags through the resume argv (buildResumeArgv).
|
|
99
110
|
permissionMode: args.permissionMode,
|
|
100
111
|
effortLevel: args.effortLevel,
|
|
112
|
+
// Story 056 (R3.2): an agent-selecting re-spawn carries the persona through too (--agent).
|
|
113
|
+
agent: args.agent,
|
|
114
|
+
// Story 057 (R1.3/R3.1): re-thread the resolved additional-directory list so an in-place
|
|
115
|
+
// re-spawn keeps the SAME `--add-dir` scope (this resume call also serves respawnSession).
|
|
116
|
+
additionalDirectories: args.additionalDirectories,
|
|
117
|
+
// Story 057 (R2.2/R2.4): re-thread the CURRENT MCP scratch path so the re-spawned `claude`
|
|
118
|
+
// carries `--mcp-config "<file>"`; respawnSession regenerates the scratch before this call.
|
|
119
|
+
mcpConfigFile: args.mcpConfigFile,
|
|
101
120
|
});
|
|
102
121
|
if (args.inPlaceRespawn) {
|
|
103
122
|
// === SEAM(046 R3.4 LIVE FIX): DEFER discovery for an in-place re-spawn ======================
|
|
@@ -169,13 +188,26 @@ export async function defaultStartEngine(args) {
|
|
|
169
188
|
baseEnv: args.baseEnv,
|
|
170
189
|
sessions: args.sessions,
|
|
171
190
|
spawn: args.spawn,
|
|
191
|
+
// Story 056 v4: a FRESH in-place re-spawn (a pre-interaction selector change) reuses the session's
|
|
192
|
+
// existing id; a normal createSession passes none here (inPlaceRespawn absent) → fresh randomUUID.
|
|
193
|
+
sessionId: args.inPlaceRespawn ? args.sessionId : undefined,
|
|
172
194
|
// Story 046 (R3.2/R2.2): the seeded permission mode + effort → `--permission-mode`/`--effort` on
|
|
173
195
|
// the fresh spawn (non-"default" only; "default"/undefined keep the byte-for-byte pre-046 argv).
|
|
174
196
|
permissionMode: args.permissionMode,
|
|
175
197
|
effortLevel: args.effortLevel,
|
|
198
|
+
// Story 056 (R3.2): the agent-selecting re-spawn's persona → `--agent "<name>"` (non-"default").
|
|
199
|
+
agent: args.agent,
|
|
176
200
|
// Story 034 (§9): the per-session gate scratch settings, already on disk — claude reads them at
|
|
177
201
|
// startup, so the hook gates the FIRST tool call (blocker c). Absent → ungated (pre-034) spawn.
|
|
178
202
|
settingsFile: args.settingsFile,
|
|
203
|
+
// Story 057 (R1.3/R3.1): the resolved additional-directory list → one `--add-dir "<dir>"` per
|
|
204
|
+
// safe entry on the fresh interactive spawn (always-on; engine sanitizes per-dir). Empty/absent
|
|
205
|
+
// keeps the pre-057 argv byte-for-byte.
|
|
206
|
+
additionalDirectories: args.additionalDirectories,
|
|
207
|
+
// Story 057 (R2.2): the fork's MCP scratch path → `--mcp-config "<file>"` (never `--strict`,
|
|
208
|
+
// R2.2 merge); written on disk BEFORE this call so claude reads it at startup. Absent (no MCP
|
|
209
|
+
// servers declared) keeps the pre-057 argv byte-for-byte.
|
|
210
|
+
mcpConfigFile: args.mcpConfigFile,
|
|
179
211
|
});
|
|
180
212
|
// Hand the engine the cancellation handle for the background poll. STORE-ONLY here — the
|
|
181
213
|
// cleanup→`.abort()` wiring (so tearing a never-interacted session down cancels this dangling poll)
|
|
@@ -228,13 +260,31 @@ export async function defaultStartEngine(args) {
|
|
|
228
260
|
cwd: args.cwd,
|
|
229
261
|
};
|
|
230
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Story 057 (R1.3/R3.1): resolve the session's additional-directory list from the request — the
|
|
265
|
+
* top-level `additionalDirectories` (the ACP field), else the legacy `_meta.additionalRoots`, else
|
|
266
|
+
* `[]`. ALWAYS-ON (no `FORK_*` opt-in gate). Both the fingerprint and the spawn must resolve through
|
|
267
|
+
* THIS single helper so the stored fingerprint and the recomputed one agree for identical inputs
|
|
268
|
+
* (the compare path in {@link getOrCreateSession} vs the store path in {@link createSession}).
|
|
269
|
+
*/
|
|
270
|
+
function resolveAdditionalDirs(p) {
|
|
271
|
+
// `_meta` is the loose ACP index-signature bag (`{ [k]: unknown } | null`); the additionalRoots
|
|
272
|
+
// fallback lives at the `NewSessionMeta` top level, so narrow through that shape (the same cast the
|
|
273
|
+
// newSession handler uses for `_meta.claudeCode.options`) rather than introducing `any`.
|
|
274
|
+
const roots = p._meta?.additionalRoots;
|
|
275
|
+
return p.additionalDirectories ?? roots ?? [];
|
|
276
|
+
}
|
|
231
277
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
232
278
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
233
279
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
234
|
-
* so that ordering differences don't trigger unnecessary recreations.
|
|
280
|
+
* so that ordering differences don't trigger unnecessary recreations.
|
|
281
|
+
* Story 057 (R1.3): the resolved additional-directory set is folded in (SORTED, so input order is
|
|
282
|
+
* irrelevant) — a changed `--add-dir` set therefore changes the fingerprint and forces a re-spawn,
|
|
283
|
+
* while a reordered-but-equal set does not. */
|
|
235
284
|
function computeSessionFingerprint(params) {
|
|
236
285
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
237
|
-
|
|
286
|
+
const dirs = [...resolveAdditionalDirs(params)].sort();
|
|
287
|
+
return JSON.stringify({ cwd: params.cwd, mcpServers: servers, additionalDirectories: dirs });
|
|
238
288
|
}
|
|
239
289
|
// === SEAM(012/023): the engine binary is resolved from the user's PATH. After the 023 rewrite,
|
|
240
290
|
// createSession no longer passes the SDK `pathToClaudeCodeExecutable`; the PTY engine (story 013)
|
|
@@ -518,6 +568,12 @@ export class ClaudeAcpAgent {
|
|
|
518
568
|
// OFF at this seam so directly-constructed test agents spin no gate unless they opt in.
|
|
519
569
|
this.gateEnabled = deps.gate ?? false;
|
|
520
570
|
this.gateOptions = deps.gateOptions;
|
|
571
|
+
// Story 056 (R3.2): main-thread agent-persona discovery — defaults to the glob-only
|
|
572
|
+
// discoverAgents; tests inject an in-memory fake so the `agent` surface is hermetic.
|
|
573
|
+
this.discoverAgents = deps.discoverAgents ?? discoverAgents;
|
|
574
|
+
// Story 056 (#812): end-of-turn session_info_update title source — defaults to the pure SDK
|
|
575
|
+
// getSessionInfo; tests inject an in-memory fake so the push is hermetic (no real ~/.claude read).
|
|
576
|
+
this.getSessionInfo = deps.getSessionInfo ?? getSessionInfo;
|
|
521
577
|
}
|
|
522
578
|
async initialize(request) {
|
|
523
579
|
this.clientCapabilities = request.clientCapabilities;
|
|
@@ -746,7 +802,19 @@ export class ClaudeAcpAgent {
|
|
|
746
802
|
// SYNCHRONOUSLY before the `await promise`, so the PTY write is committed (and the detector is
|
|
747
803
|
// reachable by the live pump and the cancel path) the instant the turn begins.
|
|
748
804
|
// (1) Assemble the PTY text payload from the ContentBlock[] (Task 1 rewrote this to return text).
|
|
749
|
-
|
|
805
|
+
// Story 058 (R2.1/R2.2): pass a fresh sink so every image promptToClaude materializes is recorded
|
|
806
|
+
// on the session BEFORE the prompt is sent — so the turn-settle + teardown cleanups can unlink it
|
|
807
|
+
// and leave no orphan temp image.
|
|
808
|
+
const turnTempImagePaths = [];
|
|
809
|
+
// Bind the sink to the session BEFORE calling promptToClaude (same array by reference): even if a
|
|
810
|
+
// future promptToClaude were to throw mid-materialize, the teardown cleanup still reaches the paths.
|
|
811
|
+
sessionRecord.turnTempImagePaths = turnTempImagePaths;
|
|
812
|
+
const payload = promptToClaude(params, this.logger, turnTempImagePaths);
|
|
813
|
+
// Story 060 (R2.2) — while ultracode is active, prefix the OUTGOING prompt with the `ultracode`
|
|
814
|
+
// keyword (the binary's per-turn Workflow opt-in). This is the LIVE activation that needs no
|
|
815
|
+
// re-spawn (Option A) and works pre- AND post-first-interaction; the scratch settings keys are the
|
|
816
|
+
// declarative spawn-time complement. NEVER emitted as a `/effort` value (R1.2).
|
|
817
|
+
const outgoing = sessionRecord.ultracodeActive ? `${ULTRACODE_EFFORT} ${payload}` : payload;
|
|
750
818
|
// (2) Register the turn with the story-024 resolver: the detector that the live pump feeds, and
|
|
751
819
|
// the awaitable that settles ONCE with { stopReason: mapStopReason(...) } on the terminal
|
|
752
820
|
// boundary (or rejects on the watchdog). One shared `schedule` drives sendPrompt + the resolver.
|
|
@@ -754,6 +822,10 @@ export class ClaudeAcpAgent {
|
|
|
754
822
|
schedule: this.schedule,
|
|
755
823
|
sessionId: params.sessionId,
|
|
756
824
|
logger: this.logger,
|
|
825
|
+
// Story 056 (#812): on a REAL end-of-turn boundary (never cancel, never watchdog), push the
|
|
826
|
+
// sanitized session title via session_info_update. `void` = fire-and-forget — the async method
|
|
827
|
+
// is never awaited, so it cannot delay the `return await promise` below (R5.1).
|
|
828
|
+
onTurnResolved: () => void this.emitSessionTitleUpdate(params.sessionId),
|
|
757
829
|
});
|
|
758
830
|
sessionRecord.turnDetector = detector;
|
|
759
831
|
sessionRecord.turnCancel = cancel;
|
|
@@ -762,12 +834,15 @@ export class ClaudeAcpAgent {
|
|
|
762
834
|
// On a PTY-write failure, reject the pending prompt via the throw — markCancelled clears the
|
|
763
835
|
// detector's Δt + watchdog timers so nothing is left hung — rather than swallowing the error.
|
|
764
836
|
try {
|
|
765
|
-
sendPrompt(sessionRecord.pty,
|
|
837
|
+
sendPrompt(sessionRecord.pty, outgoing, this.schedule);
|
|
766
838
|
}
|
|
767
839
|
catch (e) {
|
|
768
840
|
detector.markCancelled();
|
|
769
841
|
sessionRecord.turnDetector = undefined;
|
|
770
842
|
sessionRecord.turnCancel = undefined;
|
|
843
|
+
// Story 058 (R2.1): the turn never reached the model — drop its materialized temp images now.
|
|
844
|
+
cleanupMaterializedImages(sessionRecord.turnTempImagePaths);
|
|
845
|
+
sessionRecord.turnTempImagePaths = undefined;
|
|
771
846
|
throw e;
|
|
772
847
|
}
|
|
773
848
|
// (4) Resolve ONLY via the detector's terminal boundary. The pump feeds raw JSONL messages to
|
|
@@ -778,6 +853,10 @@ export class ClaudeAcpAgent {
|
|
|
778
853
|
finally {
|
|
779
854
|
sessionRecord.turnDetector = undefined;
|
|
780
855
|
sessionRecord.turnCancel = undefined;
|
|
856
|
+
// Story 058 (R2.1/R2.2): the turn is over — resolved OR cancelled (both settle this same
|
|
857
|
+
// promise, per the comment below) — so unlink the temp images materialized for it. No orphans.
|
|
858
|
+
cleanupMaterializedImages(sessionRecord.turnTempImagePaths);
|
|
859
|
+
sessionRecord.turnTempImagePaths = undefined;
|
|
781
860
|
// Story 044 (R2.3): the turn is over — resolved OR cancelled, both settle this same promise —
|
|
782
861
|
// so the in-turn sub-agent watcher dies with it (covers turn-resolve AND markCancelled paths).
|
|
783
862
|
sessionRecord.subagentWatcher?.stop();
|
|
@@ -786,6 +865,36 @@ export class ClaudeAcpAgent {
|
|
|
786
865
|
this.flushPendingControlInjections(sessionRecord);
|
|
787
866
|
}
|
|
788
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Story 056 (#812) — push the sanitized session title to the client via `session_info_update`,
|
|
870
|
+
* fired (fire-and-forget) by the story-024 end-of-turn boundary ONLY (never on cancel/watchdog,
|
|
871
|
+
* via {@link TurnResolverOptions.onTurnResolved}). DEDUPED against {@link Session.lastEmittedTitle}
|
|
872
|
+
* so an unchanged title is not re-emitted, and silent when `getSessionInfo` finds no transcript /
|
|
873
|
+
* the title is empty. Every error is swallowed and logged — this MUST NEVER reject the turn (it is
|
|
874
|
+
* never awaited in `prompt()`), and a slow/never-resolving reader cannot delay the PromptResponse.
|
|
875
|
+
*/
|
|
876
|
+
async emitSessionTitleUpdate(sessionId) {
|
|
877
|
+
const session = this.sessions[sessionId];
|
|
878
|
+
if (!session)
|
|
879
|
+
return;
|
|
880
|
+
try {
|
|
881
|
+
const info = await this.getSessionInfo(sessionId, { dir: session.cwd });
|
|
882
|
+
if (!info)
|
|
883
|
+
return; // no transcript / not found → nothing to push
|
|
884
|
+
const title = sanitizeTitle(info.summary);
|
|
885
|
+
if (!title || title === session.lastEmittedTitle)
|
|
886
|
+
return; // dedup + never push empty
|
|
887
|
+
session.lastEmittedTitle = title;
|
|
888
|
+
await this.client.sessionUpdate({
|
|
889
|
+
sessionId,
|
|
890
|
+
update: { sessionUpdate: "session_info_update", title },
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
// Swallow — never reject the turn (this method is never awaited from prompt()).
|
|
895
|
+
this.logger.error("[acp-agent] session title push (#812) failed:", err);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
789
898
|
async cancel(params) {
|
|
790
899
|
const sessionRecord = this.sessions[params.sessionId];
|
|
791
900
|
if (!sessionRecord) {
|
|
@@ -832,6 +941,12 @@ export class ClaudeAcpAgent {
|
|
|
832
941
|
return;
|
|
833
942
|
}
|
|
834
943
|
await this.cancel({ sessionId });
|
|
944
|
+
// Story 058 (R2.1): idempotent backstop — unlink any temp images that survived a turn whose
|
|
945
|
+
// prompt-finally never ran (e.g. a session torn down between turns or before the finally fired).
|
|
946
|
+
// The cancel above may have already cleared them; cleanupMaterializedImages never throws on a
|
|
947
|
+
// gone file, so a double-cleanup is a safe no-op.
|
|
948
|
+
cleanupMaterializedImages(session.turnTempImagePaths);
|
|
949
|
+
session.turnTempImagePaths = undefined;
|
|
835
950
|
// Story 044 (R2.3): stop the sub-agent watcher on teardown — idempotent with the prompt-finally stop.
|
|
836
951
|
session.subagentWatcher?.stop();
|
|
837
952
|
session.subagentWatcher = undefined;
|
|
@@ -857,6 +972,12 @@ export class ClaudeAcpAgent {
|
|
|
857
972
|
if (session.gate) {
|
|
858
973
|
await session.gate.teardown();
|
|
859
974
|
}
|
|
975
|
+
// Story 057 (R2.3): remove the MCP scratch on teardown so no secret-bearing file (auth headers/
|
|
976
|
+
// env) is orphaned. Idempotent + never throws + never logs the contents; a no-op when the session
|
|
977
|
+
// declared no MCP servers (mcpConfigFile undefined) or after a re-spawn already swapped/removed it.
|
|
978
|
+
if (session.mcpConfigFile) {
|
|
979
|
+
await removeMcpScratch(session.mcpConfigFile);
|
|
980
|
+
}
|
|
860
981
|
this.engines.delete(sessionId);
|
|
861
982
|
delete this.sessions[sessionId];
|
|
862
983
|
}
|
|
@@ -931,17 +1052,29 @@ export class ClaudeAcpAgent {
|
|
|
931
1052
|
const resolvedValue = validValue.value;
|
|
932
1053
|
if (params.configId === "mode") {
|
|
933
1054
|
await this.applySessionMode(params.sessionId, resolvedValue);
|
|
1055
|
+
// Story 056 v4 (OPTIMISTIC notify) — for a CYCLABLE mode, push `current_mode_update` to the panel
|
|
1056
|
+
// BEFORE driving the (slower) closed-loop Shift+Tab cycle, so the selector reflects the choice
|
|
1057
|
+
// INSTANTLY instead of waiting out the cycle. Safe: the permission gate reads the mode from the
|
|
1058
|
+
// transcript (tail-as-truth), NOT this notification, and a cyclable drive never re-spawns (no R3.7
|
|
1059
|
+
// rollback to honor). dontAsk/bypass (a re-spawn that CAN fail) keep the notify AFTER the drive so
|
|
1060
|
+
// a failed switch does not leave the panel showing a mode that never applied.
|
|
1061
|
+
const cyclable = CYCLABLE_MODES.has(resolvedValue);
|
|
1062
|
+
if (cyclable) {
|
|
1063
|
+
await this.client.sessionUpdate({
|
|
1064
|
+
sessionId: params.sessionId,
|
|
1065
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: resolvedValue },
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
934
1068
|
// Bug A fix (Story 046 R3) — Zed sends mode changes via set_config_option(configId:"mode"), so
|
|
935
1069
|
// the DRIVE must happen HERE too. This path used to only validate (read-only), leaving claude
|
|
936
1070
|
// stuck on its spawn mode — the live permission mode never changed (bypass/acceptEdits no-op'd).
|
|
937
1071
|
await this.driveModeIntoTui(params.sessionId, session, resolvedValue);
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
sessionUpdate: "current_mode_update",
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
1072
|
+
if (!cyclable) {
|
|
1073
|
+
await this.client.sessionUpdate({
|
|
1074
|
+
sessionId: params.sessionId,
|
|
1075
|
+
update: { sessionUpdate: "current_mode_update", currentModeId: resolvedValue },
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
945
1078
|
}
|
|
946
1079
|
// === SEAM(023→046) Group 1: the dropped SDK `query.setModel` is replaced by a PTY side-channel.
|
|
947
1080
|
// `/model <alias>` is a LOCAL TUI command (no assistant turn, no stop_reason) — inject it as a
|
|
@@ -953,10 +1086,19 @@ export class ClaudeAcpAgent {
|
|
|
953
1086
|
this.applyModelSwitch(session, resolvedValue);
|
|
954
1087
|
}
|
|
955
1088
|
else if (params.configId === "effort") {
|
|
956
|
-
// Story
|
|
957
|
-
// re-
|
|
958
|
-
// applyConfigOptionValue below
|
|
959
|
-
|
|
1089
|
+
// Story 056 v4: effort is now a LIVE `/effort <level>` injection (claude 2.1.195 has the command),
|
|
1090
|
+
// mirroring /model — no re-spawn, works before the first interaction. Mid-turn it defers; it never
|
|
1091
|
+
// throws, so applyConfigOptionValue below always commits the new currentValue (optimistic, like
|
|
1092
|
+
// /model). The flag path stays only to seed/preserve effort across mode/agent re-spawns.
|
|
1093
|
+
// Story 060 (R2/R3.2): route through applyEffortSelection so the `ultracode` sentinel is
|
|
1094
|
+
// special-cased (activate keyword + scratch keys + /effort xhigh) while real levels deselect it.
|
|
1095
|
+
await this.applyEffortSelection(session, resolvedValue);
|
|
1096
|
+
}
|
|
1097
|
+
else if (params.configId === "agent") {
|
|
1098
|
+
// Story 056 (R3.3/R3.4): the agent persona has no live mid-session path either — apply it by an
|
|
1099
|
+
// in-place re-spawn carrying `--agent` (mirrors effort). On throw, applyConfigOptionValue below is
|
|
1100
|
+
// skipped so the prior currentValue stays unchanged (R3.7-style failure path).
|
|
1101
|
+
await this.applyAgentChange(params.sessionId, session, resolvedValue);
|
|
960
1102
|
}
|
|
961
1103
|
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
|
|
962
1104
|
return { configOptions: session.configOptions };
|
|
@@ -1066,6 +1208,12 @@ export class ClaudeAcpAgent {
|
|
|
1066
1208
|
session.pendingModelInjection = undefined;
|
|
1067
1209
|
this.injectModelCommand(session, alias);
|
|
1068
1210
|
}
|
|
1211
|
+
// Story 056 v4 — flush a deferred effort `/effort <level>` injection too (last-write-wins).
|
|
1212
|
+
const effort = session.pendingEffortInjection;
|
|
1213
|
+
if (effort !== undefined) {
|
|
1214
|
+
session.pendingEffortInjection = undefined;
|
|
1215
|
+
this.injectEffortCommand(session, effort);
|
|
1216
|
+
}
|
|
1069
1217
|
}
|
|
1070
1218
|
/**
|
|
1071
1219
|
* Story 046 (R3.3, design §6b) — drive the TUI to `target` with closed-loop raw Shift+Tab. Writes
|
|
@@ -1126,7 +1274,9 @@ export class ClaudeAcpAgent {
|
|
|
1126
1274
|
* Order is load-bearing for R3.7: re-spawn FIRST, and swap in + tear down the old PTY ONLY once the new
|
|
1127
1275
|
* one is live — so a failed re-spawn leaves the prior PTY/currentValue intact (never
|
|
1128
1276
|
* 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).
|
|
1277
|
+
* double-resolve. The `respawning` latch defers concurrent selector changes (R3.8). Re-spawning for
|
|
1278
|
+
* ONE selector preserves the OTHER two (mode / effort / agent — Story 056 added agent) by reading
|
|
1279
|
+
* their current values, so the resume argv always carries all three flags.
|
|
1130
1280
|
*/
|
|
1131
1281
|
async respawnSession(sessionId, session, change) {
|
|
1132
1282
|
// Story 046 (R3.4 LIVE FIX guard): a re-spawn reattaches via `claude --resume <id>`, which needs the
|
|
@@ -1136,33 +1286,75 @@ export class ClaudeAcpAgent {
|
|
|
1136
1286
|
// once. The user/Zed retries after the first prompt; a boot-time default_config_options dontAsk/
|
|
1137
1287
|
// bypass therefore stays at the fresh spawn's mode (use a fresh-spawn --permission-mode seed for
|
|
1138
1288
|
// start-in-bypass, a documented follow-up). The OTHER selector's currentValue is left unchanged.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1289
|
+
// Story 056 v4 — before the first interaction there is NO transcript to `--resume`, so the re-spawn
|
|
1290
|
+
// is FRESH (reusing the SAME sessionId — LIVE-VERIFIED: claude accepts a reused --session-id once the
|
|
1291
|
+
// prior PTY exits). After the first interaction, `--resume` reattaches the transcript (R3.4). This is
|
|
1292
|
+
// what lets the agent/effort/mode selectors apply BEFORE the first prompt — the live gap the user hit
|
|
1293
|
+
// (previously this threw and the selector silently reverted). NOTE: effort no longer reaches here
|
|
1294
|
+
// (it is a live `/effort` inject now); the fresh path serves agent and the dontAsk/bypass modes.
|
|
1295
|
+
const fresh = !session.interacted;
|
|
1142
1296
|
session.respawning = true;
|
|
1143
1297
|
try {
|
|
1144
1298
|
const oldEngine = session.engine;
|
|
1145
|
-
// Preserve the OTHER
|
|
1146
|
-
// reset the
|
|
1299
|
+
// Preserve the OTHER selectors' current values so re-spawning for one (mode, effort, OR agent)
|
|
1300
|
+
// does not reset the others — the argv carries all three flags. There are three selectors now
|
|
1301
|
+
// (Story 056 added agent): a mode re-spawn keeps effort+agent, an agent re-spawn keeps mode+effort.
|
|
1147
1302
|
const permissionMode = change.permissionMode ?? session.modes.currentModeId;
|
|
1148
|
-
|
|
1303
|
+
// Story 060 (R1.2 fix): the preserved currentEffort can be the `ultracode` SENTINEL (it is the
|
|
1304
|
+
// committed configOption value while active). It is NOT a real `--effort` enum value — the binary
|
|
1305
|
+
// rejects `--effort ultracode` (story-060 probe), so a mode/agent re-spawn-while-active would
|
|
1306
|
+
// silently degrade effort to default. Map the sentinel to its real component (xhigh) at THIS spawn
|
|
1307
|
+
// seam (it feeds BOTH buildClaudeCmd and buildResumeArgv); the scratch `ultracode:true` already
|
|
1308
|
+
// carries the orchestration activation declaratively at spawn.
|
|
1309
|
+
const preservedEffort = change.effortLevel ?? this.currentEffort(session);
|
|
1310
|
+
const effortLevel = preservedEffort === ULTRACODE_EFFORT ? ULTRACODE_EFFORT_LEVEL : preservedEffort;
|
|
1311
|
+
const agent = change.agent ?? this.currentAgent(session);
|
|
1312
|
+
// Story 057 (R2.4): REGENERATE the MCP scratch so the re-spawned `claude` reads the CURRENT MCP
|
|
1313
|
+
// config at startup (its `--mcp-config` is bound only at spawn). Re-translate from the stored raw
|
|
1314
|
+
// ACP servers (kept faithful to the original request). Write the NEW scratch BEFORE removing the
|
|
1315
|
+
// OLD so a write failure leaves the prior scratch intact (the still-running old PTY already read
|
|
1316
|
+
// its config at startup, so removing the old file does not disturb it). removeMcpScratch is
|
|
1317
|
+
// idempotent + never throws + never logs the contents (R2.3).
|
|
1318
|
+
if (session.mcpServers && session.mcpServers.length > 0) {
|
|
1319
|
+
const old = session.mcpConfigFile;
|
|
1320
|
+
session.mcpConfigFile = await writeMcpScratch(translateMcpServers(session.mcpServers));
|
|
1321
|
+
if (old)
|
|
1322
|
+
await removeMcpScratch(old);
|
|
1323
|
+
}
|
|
1324
|
+
// FRESH re-spawn (pre-interaction): retire the old fresh PTY FIRST to free the reused sessionId —
|
|
1325
|
+
// there is no transcript to preserve. RESUME re-spawn: keep the R3.7 order (bring the new PTY up
|
|
1326
|
+
// BEFORE retiring the old, so a failed re-spawn leaves the prior PTY + currentValue intact).
|
|
1327
|
+
if (fresh) {
|
|
1328
|
+
oldEngine?.cleanup();
|
|
1329
|
+
oldEngine?.kill();
|
|
1330
|
+
}
|
|
1149
1331
|
// Re-spawn through the SAME startEngine seam createSession uses, reusing the sessionId so the
|
|
1150
|
-
// transcript is reattached (R3.4)
|
|
1332
|
+
// transcript is reattached on resume (R3.4) or freshly created on the pre-interaction path; the
|
|
1333
|
+
// flags flow into the flag-carrying spawn argv (fresh `buildClaudeCmd` or `buildResumeArgv`).
|
|
1151
1334
|
const started = await this.startEngine({
|
|
1152
1335
|
sessionId,
|
|
1153
1336
|
cwd: session.cwd,
|
|
1154
|
-
resume:
|
|
1155
|
-
// Story 046 (R3.4 LIVE FIX): an in-place re-spawn may run before the first interaction
|
|
1156
|
-
//
|
|
1337
|
+
resume: !fresh,
|
|
1338
|
+
// Story 046 (R3.4 LIVE FIX): an in-place re-spawn may run before the first interaction, so DEFER
|
|
1339
|
+
// discovery instead of the 2000ms fatal watchdog — see defaultStartEngine.
|
|
1157
1340
|
inPlaceRespawn: true,
|
|
1158
1341
|
permissionMode,
|
|
1159
1342
|
effortLevel,
|
|
1343
|
+
agent,
|
|
1344
|
+
// Story 057 (R1.3/R3.1): re-thread the SAME `--add-dir` scope into the re-spawn (sub-task 1.2
|
|
1345
|
+
// wired only the fresh createSession path; the in-place re-spawn must preserve it too).
|
|
1346
|
+
additionalDirectories: session.additionalDirectories,
|
|
1347
|
+
// Story 057 (R2.4): the freshly-regenerated MCP scratch path (see above) → `--mcp-config` on
|
|
1348
|
+
// the re-spawned `claude`, so it carries the current MCP config.
|
|
1349
|
+
mcpConfigFile: session.mcpConfigFile,
|
|
1160
1350
|
sessions: this.engines,
|
|
1161
1351
|
onEvent: (sid) => void this.pumpUpdates(sid),
|
|
1162
1352
|
});
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1353
|
+
if (!fresh) {
|
|
1354
|
+
// New PTY is live — only now retire the old one (idle ⇒ no pending turn to double-resolve).
|
|
1355
|
+
oldEngine?.cleanup();
|
|
1356
|
+
oldEngine?.kill();
|
|
1357
|
+
}
|
|
1166
1358
|
session.pty = started.pty;
|
|
1167
1359
|
session.engine = started.engine;
|
|
1168
1360
|
session.watcher = started.watcher;
|
|
@@ -1179,20 +1371,102 @@ export class ClaudeAcpAgent {
|
|
|
1179
1371
|
const opt = session.configOptions.find((o) => o.id === "effort");
|
|
1180
1372
|
return typeof opt?.currentValue === "string" ? opt.currentValue : undefined;
|
|
1181
1373
|
}
|
|
1374
|
+
/** Story 056 — the session's current agent configOption value (undefined when no agent option). */
|
|
1375
|
+
currentAgent(session) {
|
|
1376
|
+
const opt = session.configOptions.find((o) => o.id === "agent");
|
|
1377
|
+
return typeof opt?.currentValue === "string" ? opt.currentValue : undefined;
|
|
1378
|
+
}
|
|
1182
1379
|
/**
|
|
1183
|
-
* Story
|
|
1184
|
-
*
|
|
1185
|
-
* (
|
|
1186
|
-
*
|
|
1187
|
-
* the
|
|
1380
|
+
* Story 060 (R2/R3.2) — apply an effort-selector choice, special-casing the `ultracode` sentinel.
|
|
1381
|
+
*
|
|
1382
|
+
* Selecting `ultracode` (Option A — keyword + scratch, NO re-spawn): activate the session flag (which
|
|
1383
|
+
* makes {@link prompt} prefix the OUTGOING prompt with the `ultracode` keyword — the binary's per-turn
|
|
1384
|
+
* Workflow opt-in, the effective live mechanism), write the scratch ultracode keys via
|
|
1385
|
+
* {@link applyUltracodeSettings} (the declarative spawn-time complement), and set the effort to xhigh
|
|
1386
|
+
* through the SAME live `/effort` inject as every other level — NEVER `/effort ultracode` (R1.2). The
|
|
1387
|
+
* `already` guard suppresses a redundant `/effort xhigh` re-inject when ultracode is re-selected while
|
|
1388
|
+
* already active.
|
|
1389
|
+
*
|
|
1390
|
+
* Selecting a real level (or `default`) DEACTIVATES ultracode: clear the flag, remove the scratch keys,
|
|
1391
|
+
* then apply that level through {@link applyEffortChange} (whose own no-op guard handles a same-level
|
|
1392
|
+
* pick). `applyConfigOptionValue` (the caller, after this returns) commits the selector's currentValue,
|
|
1393
|
+
* which for `ultracode` correctly stays `"ultracode"` (the {@link buildConfigOptions} `includes` guard
|
|
1394
|
+
* keeps it valid across rebuilds).
|
|
1188
1395
|
*/
|
|
1189
|
-
async
|
|
1396
|
+
async applyEffortSelection(session, value) {
|
|
1397
|
+
if (value === ULTRACODE_EFFORT) {
|
|
1398
|
+
const already = session.ultracodeActive === true;
|
|
1399
|
+
session.ultracodeActive = true;
|
|
1400
|
+
// The gate's per-session SCRATCH settings file is the spawn's `--settings` target; on a live
|
|
1401
|
+
// Session it is reachable via `session.gate?.settingsPath` (the value createSession also threads
|
|
1402
|
+
// into StartEngineArgs.settingsFile). Absent on a no-gate / resume / replay session → keyword-only.
|
|
1403
|
+
const scratchPath = session.gate?.settingsPath;
|
|
1404
|
+
if (scratchPath) {
|
|
1405
|
+
// Declarative scratch keys for any future (re-)spawn (NOT a re-spawn trigger — Option A).
|
|
1406
|
+
await applyUltracodeSettings(scratchPath, true);
|
|
1407
|
+
}
|
|
1408
|
+
// Effort component is xhigh, applied via the live /effort inject — but only when not already active
|
|
1409
|
+
// (re-selecting ultracode must not re-inject `/effort xhigh`). applyEffortChange's own no-op guard
|
|
1410
|
+
// also short-circuits if xhigh already equals the current effort.
|
|
1411
|
+
if (!already) {
|
|
1412
|
+
this.applyEffortChange(session, ULTRACODE_EFFORT_LEVEL);
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
// A real level (or `default`) was chosen → deactivate ultracode before applying it.
|
|
1417
|
+
if (session.ultracodeActive) {
|
|
1418
|
+
session.ultracodeActive = false;
|
|
1419
|
+
const scratchPath = session.gate?.settingsPath;
|
|
1420
|
+
if (scratchPath) {
|
|
1421
|
+
await applyUltracodeSettings(scratchPath, false);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
this.applyEffortChange(session, value);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Story 046 (R2.2) + Story 056 v4 — apply a reasoning-effort change LIVE via `/effort <level>`.
|
|
1428
|
+
* SUPERSEDES the 046 Probe-B re-spawn: `claude` 2.1.195 DOES have a live `/effort <level>` local TUI
|
|
1429
|
+
* command (LIVE-VERIFIED — "Set effort level to high…", applied inline, NO "Switch?" dialog unlike
|
|
1430
|
+
* /model). So effort now mirrors {@link applyModelSwitch}: a side-channel write, no re-spawn, no turn —
|
|
1431
|
+
* which means it ALSO works BEFORE the first interaction (the re-spawn's --resume idle-guard was why
|
|
1432
|
+
* effort silently failed pre-first-prompt). Mid-turn it defers (pendingEffortInjection) and flushes
|
|
1433
|
+
* when the turn settles. A no-op change applies nothing; effort stays preserved across mode/agent
|
|
1434
|
+
* re-spawns (currentEffort → --effort flag), so the spawn-flag path remains as the seed/preserve route.
|
|
1435
|
+
*/
|
|
1436
|
+
applyEffortChange(session, level) {
|
|
1190
1437
|
if (level === this.currentEffort(session))
|
|
1191
1438
|
return; // no value change → no-op
|
|
1439
|
+
if (session.turnDetector !== undefined) {
|
|
1440
|
+
// A turn is in flight — injecting mid-turn corrupts the PTY input. Defer (coalesce, mirrors /model).
|
|
1441
|
+
session.pendingEffortInjection = level;
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
this.injectEffortCommand(session, level);
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Side-channel `/effort <level>` write — synchronous, resolves immediately (never a turn). Unlike
|
|
1448
|
+
* `/model`, `/effort` applies INLINE with no blocking "Switch?" dialog (LIVE-VERIFIED 2.1.195), so
|
|
1449
|
+
* sendPrompt's own submit `\r` is sufficient and NO confirm Enter is scheduled.
|
|
1450
|
+
*/
|
|
1451
|
+
injectEffortCommand(session, level) {
|
|
1452
|
+
sendPrompt(session.pty, `/effort ${level}`, (fn) => fn());
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Story 056 (R3.3/R3.4) — apply a main-thread agent-persona change. Like effort, the persona has no
|
|
1456
|
+
* live mid-session mechanism (`--agent "<name>"` is a spawn flag), so a change re-spawns in place
|
|
1457
|
+
* carrying the flag (mirroring {@link applyEffortChange}), idle-guarded, with the R3.7 failure path
|
|
1458
|
+
* and the R3.8 latch. A no-op change (same persona, with the "default" sentinel as the no-persona
|
|
1459
|
+
* baseline) applies nothing. Throwing here leaves the caller's applyConfigOptionValue unrun, so the
|
|
1460
|
+
* prior currentValue is left unchanged on failure (R3.7). Optimistic-on-apply, like effort: there is
|
|
1461
|
+
* no transcript drift event for the agent persona, so it is NOT reconciled afterward (R4.3).
|
|
1462
|
+
*/
|
|
1463
|
+
async applyAgentChange(sessionId, session, agent) {
|
|
1464
|
+
if (agent === (this.currentAgent(session) ?? "default"))
|
|
1465
|
+
return; // no value change → no-op
|
|
1192
1466
|
if (session.turnDetector !== undefined || session.respawning) {
|
|
1193
|
-
throw new Error("Cannot change
|
|
1467
|
+
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
1468
|
}
|
|
1195
|
-
await this.respawnSession(sessionId, session, {
|
|
1469
|
+
await this.respawnSession(sessionId, session, { agent });
|
|
1196
1470
|
}
|
|
1197
1471
|
/**
|
|
1198
1472
|
* Story 046 (R4.1/R4.2/R4.3, design §8) — reconcile the `mode` configOption from the latest
|
|
@@ -1708,7 +1982,12 @@ export class ClaudeAcpAgent {
|
|
|
1708
1982
|
// Rebuild config options since effort levels depend on the selected model
|
|
1709
1983
|
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1710
1984
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
1711
|
-
|
|
1985
|
+
// Story 056 (R3.2): preserve the `agent` option across a model switch — re-read its current
|
|
1986
|
+
// value and rebuild from the session's stored catalog (no re-glob). `session.agents ?? []`
|
|
1987
|
+
// keeps the option absent when none were discovered (the gate).
|
|
1988
|
+
const agentOpt = session.configOptions.find((o) => o.id === "agent");
|
|
1989
|
+
const currentAgent = typeof agentOpt?.currentValue === "string" ? agentOpt.currentValue : undefined;
|
|
1990
|
+
session.configOptions = buildConfigOptions(session.modes, value, session.modelInfos, currentEffort, session.agents ?? [], currentAgent);
|
|
1712
1991
|
// === SEAM(023) Group 1: the SDK effort sync (query.applyFlagSettings) after a model switch is
|
|
1713
1992
|
// dropped — configOptions already reflects the new effort locally.
|
|
1714
1993
|
// Degrau 2 (030/032): PTY-backed control. ===
|
|
@@ -1791,6 +2070,21 @@ export class ClaudeAcpAgent {
|
|
|
1791
2070
|
else {
|
|
1792
2071
|
requestedSessionId = randomUUID();
|
|
1793
2072
|
}
|
|
2073
|
+
// Story 057 (R1.3/R3.1): resolve the additional-directory list ONCE (always-on, no env gate) so
|
|
2074
|
+
// the SAME value threads to the spawn AND is stored on the session record (sub-task 2.3's
|
|
2075
|
+
// respawnSession re-threads it). The fingerprint resolves through the same helper — see below.
|
|
2076
|
+
const additionalDirs = resolveAdditionalDirs(params);
|
|
2077
|
+
// Story 057 (R2.2/R2.3, sub-task 2.3): WRITE the MCP scratch BEFORE the spawn when the session
|
|
2078
|
+
// declared ≥1 MCP server. Mirrors the gate's settings-scratch ordering (GATE_FINDINGS blocker c):
|
|
2079
|
+
// the file must be ON DISK before claude starts, because claude reads `--mcp-config` only at
|
|
2080
|
+
// startup. A replay-only load spawns nothing → no scratch. Always-on (no `FORK_*` gate, R3.1);
|
|
2081
|
+
// the ONLY condition is "mcpServers non-empty". The path is threaded into startEngine below and
|
|
2082
|
+
// stored on the session record (teardown removal + re-spawn regeneration). Awaited so a write
|
|
2083
|
+
// failure surfaces here (loudly) rather than racing the spawn. Never logged (R2.3).
|
|
2084
|
+
let mcpConfigFile;
|
|
2085
|
+
if (!creationOpts.replayOnly && params.mcpServers && params.mcpServers.length > 0) {
|
|
2086
|
+
mcpConfigFile = await writeMcpScratch(translateMcpServers(params.mcpServers));
|
|
2087
|
+
}
|
|
1794
2088
|
// SettingsManager is retained (kept methods read it; teardown disposes it). The PTY TUI reads
|
|
1795
2089
|
// the user's settings from disk itself — we no longer translate them into SDK `Options`.
|
|
1796
2090
|
const settingsManager = new SettingsManager(params.cwd, {
|
|
@@ -1840,6 +2134,12 @@ export class ClaudeAcpAgent {
|
|
|
1840
2134
|
permissionMode: seededMode,
|
|
1841
2135
|
// Story 034: the gate's scratch settings file, consumed as `--settings "<file>"` (fresh path).
|
|
1842
2136
|
settingsFile: gate?.settingsPath,
|
|
2137
|
+
// Story 057 (R1.3/R3.1): the resolved additional-directory list → `--add-dir` on the spawn
|
|
2138
|
+
// (always-on; the engine sanitizes per-dir). Same list stored on the session record below.
|
|
2139
|
+
additionalDirectories: additionalDirs,
|
|
2140
|
+
// Story 057 (R2.2): the MCP scratch path (written above) → `--mcp-config "<file>"` on the
|
|
2141
|
+
// spawn. Same path stored on the session record below (teardown removal + re-spawn regen).
|
|
2142
|
+
mcpConfigFile,
|
|
1843
2143
|
});
|
|
1844
2144
|
}
|
|
1845
2145
|
catch (error) {
|
|
@@ -1850,6 +2150,12 @@ export class ClaudeAcpAgent {
|
|
|
1850
2150
|
// reaches the map, so teardownSession can never dispose it. Dispose it on this path.
|
|
1851
2151
|
settingsManager.dispose();
|
|
1852
2152
|
await gate?.teardown();
|
|
2153
|
+
// Story 057 (R2.3): a failed spawn must likewise leave NO MCP scratch behind (it was written
|
|
2154
|
+
// before startEngine). removeMcpScratch is idempotent + never throws, so it cannot mask the
|
|
2155
|
+
// original spawn error rethrown below.
|
|
2156
|
+
if (mcpConfigFile) {
|
|
2157
|
+
await removeMcpScratch(mcpConfigFile);
|
|
2158
|
+
}
|
|
1853
2159
|
if (creationOpts.resume && error instanceof Error) {
|
|
1854
2160
|
throw RequestError.resourceNotFound(requestedSessionId);
|
|
1855
2161
|
}
|
|
@@ -1896,7 +2202,13 @@ export class ClaudeAcpAgent {
|
|
|
1896
2202
|
currentModeId: seededMode,
|
|
1897
2203
|
availableModes,
|
|
1898
2204
|
};
|
|
1899
|
-
|
|
2205
|
+
// Story 056 (R3.2): discover the main-thread agent personas for THIS session's cwd (glob-only via
|
|
2206
|
+
// the injectable seam). When ≥1 is found, buildConfigOptions surfaces the 4th `agent` dropdown
|
|
2207
|
+
// (seeded "default" = no persona at fresh create); when none, the option is omitted. The catalog
|
|
2208
|
+
// is stored on the session record below so the model-change reconcile rebuilds it WITHOUT
|
|
2209
|
+
// re-globbing.
|
|
2210
|
+
const agents = this.discoverAgents(params.cwd);
|
|
2211
|
+
const configOptions = buildConfigOptions(modes, DEFAULT_MODEL_INFO.value, MODEL_CATALOG, settingsManager.getSettings().effortLevel, agents, undefined);
|
|
1900
2212
|
// Runtime cwd is read from inside the JSONL (story 015); fall back to the requested host cwd
|
|
1901
2213
|
// until the first transcript line carries `.cwd` (the seam may return cwd === undefined early).
|
|
1902
2214
|
const runtimeCwd = started.cwd ?? params.cwd;
|
|
@@ -1911,6 +2223,13 @@ export class ClaudeAcpAgent {
|
|
|
1911
2223
|
engine: started.engine,
|
|
1912
2224
|
cancelled: false,
|
|
1913
2225
|
cwd: runtimeCwd,
|
|
2226
|
+
// Story 057 (R1.3): the resolved additional-directory list, stored so sub-task 2.3's
|
|
2227
|
+
// respawnSession can re-thread the SAME `--add-dir` scope into the in-place re-spawn.
|
|
2228
|
+
additionalDirectories: additionalDirs,
|
|
2229
|
+
// Story 057 (R2.3/R2.4): the CURRENT MCP scratch path (for teardown removal + re-spawn regen)
|
|
2230
|
+
// and the RAW ACP server array (so respawnSession can re-translate + regenerate the scratch).
|
|
2231
|
+
mcpConfigFile,
|
|
2232
|
+
mcpServers: params.mcpServers,
|
|
1914
2233
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
1915
2234
|
settingsManager,
|
|
1916
2235
|
accumulatedUsage: {
|
|
@@ -1921,6 +2240,7 @@ export class ClaudeAcpAgent {
|
|
|
1921
2240
|
},
|
|
1922
2241
|
modes,
|
|
1923
2242
|
modelInfos: MODEL_CATALOG,
|
|
2243
|
+
agents,
|
|
1924
2244
|
configOptions,
|
|
1925
2245
|
contextWindowSize: inferContextWindowFromModel(DEFAULT_MODEL_INFO.value) ?? DEFAULT_CONTEXT_WINDOW,
|
|
1926
2246
|
taskState,
|
|
@@ -1981,7 +2301,7 @@ function buildAvailableModes(modelInfo) {
|
|
|
1981
2301
|
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
|
|
1982
2302
|
// `"default"` sentinel and `undefined` (effort option absent for the model) to
|
|
1983
2303
|
// `null` ensures any previously-applied flag is actually cleared.
|
|
1984
|
-
function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel) {
|
|
2304
|
+
function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel, agents = [], currentAgent) {
|
|
1985
2305
|
const options = [
|
|
1986
2306
|
{
|
|
1987
2307
|
id: "mode",
|
|
@@ -2025,8 +2345,13 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
|
|
|
2025
2345
|
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
|
2026
2346
|
.join(" "),
|
|
2027
2347
|
})),
|
|
2348
|
+
// Story 060 (R1.1) — the "ultracode" sentinel, LAST and after the five real levels. NOT a real
|
|
2349
|
+
// `--effort` value (claude rejects `--effort ultracode`); it maps to xhigh + orchestration (Task 3).
|
|
2350
|
+
{ value: ULTRACODE_EFFORT, name: ULTRACODE_EFFORT_LABEL },
|
|
2028
2351
|
];
|
|
2029
|
-
|
|
2352
|
+
// `ultracode` is a valid current value so a configOptions rebuild (e.g. after a re-spawn) does not
|
|
2353
|
+
// reset a selected ultracode back to "default". It stays OUT of supportedLevels (real --effort enum).
|
|
2354
|
+
const includes = (l) => l === "default" || l === ULTRACODE_EFFORT || supportedLevels.includes(l);
|
|
2030
2355
|
const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
|
2031
2356
|
options.push({
|
|
2032
2357
|
id: "effort",
|
|
@@ -2038,6 +2363,27 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
|
|
|
2038
2363
|
options: effortOptions,
|
|
2039
2364
|
});
|
|
2040
2365
|
}
|
|
2366
|
+
// Story 056 (R3.2) — the `agent` (main-thread persona) selector, mirroring the effort option but
|
|
2367
|
+
// with a "default" no-persona sentinel. GATED on `agents.length > 0` (upstream #794): when nothing
|
|
2368
|
+
// is discovered the option is OMITTED entirely. The "default" entry = no persona (the spawn layer
|
|
2369
|
+
// already drops the literal "default", exactly like --effort/--permission-mode). The current value
|
|
2370
|
+
// is validated against the discovered set and falls back to "default".
|
|
2371
|
+
if (agents.length > 0) {
|
|
2372
|
+
const agentValues = new Set(agents.map((a) => a.value));
|
|
2373
|
+
const validAgent = currentAgent && agentValues.has(currentAgent) ? currentAgent : "default";
|
|
2374
|
+
options.push({
|
|
2375
|
+
id: "agent",
|
|
2376
|
+
name: "Agent",
|
|
2377
|
+
description: "Main-thread agent persona",
|
|
2378
|
+
category: "model",
|
|
2379
|
+
type: "select",
|
|
2380
|
+
currentValue: validAgent,
|
|
2381
|
+
options: [
|
|
2382
|
+
{ value: "default", name: "Default" },
|
|
2383
|
+
...agents.map((a) => ({ value: a.value, name: a.displayName, description: a.description })),
|
|
2384
|
+
],
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2041
2387
|
return options;
|
|
2042
2388
|
}
|
|
2043
2389
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
@@ -2167,14 +2513,27 @@ function filePathFromUri(uri) {
|
|
|
2167
2513
|
* anything below the threshold — or large but path-less — is
|
|
2168
2514
|
* inlined directly so the context is not lost.
|
|
2169
2515
|
*
|
|
2170
|
-
*
|
|
2171
|
-
* emit
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2516
|
+
* - image → materialize the base64 to a uuid-named temp file (extension from mimeType)
|
|
2517
|
+
* and emit `@<temp-path>` (Story 058 / R1.1). Once at least one image is
|
|
2518
|
+
* materialized, a single Read-inducing directive is appended after the loop so
|
|
2519
|
+
* the TUI's Read tool fires and vision-encodes it (R1.2). Each temp path is
|
|
2520
|
+
* pushed into `materializedSink` (when provided) so the caller can clean it up.
|
|
2521
|
+
*
|
|
2522
|
+
* `resource` (blob) / `audio` blocks are SILENT no-ops here (R4.1): they emit no PTY bytes
|
|
2523
|
+
* and are NOT logged — they are expected-but-unsupported media in v1, not errors. An UNKNOWN
|
|
2524
|
+
* block `type` (the `default` branch) and any block whose mapping THROWS are treated as
|
|
2525
|
+
* malformed: skipped, recorded via the `logger`, and the remaining valid blocks still map —
|
|
2526
|
+
* one bad block never aborts the whole prompt (R1.3). A `materializeImage` failure is caught
|
|
2527
|
+
* by that same per-block isolation, so a broken image is skipped, never aborting the prompt.
|
|
2528
|
+
*
|
|
2529
|
+
* `materializedSink`, when passed, receives every materialized temp path (in order) so the
|
|
2530
|
+
* caller owns their lifecycle (cleanup is a later task). The return type stays `string`.
|
|
2175
2531
|
*/
|
|
2176
|
-
export function promptToClaude(prompt, logger = console) {
|
|
2532
|
+
export function promptToClaude(prompt, logger = console, materializedSink) {
|
|
2177
2533
|
const fragments = [];
|
|
2534
|
+
// Set once any image block is materialized, so exactly ONE Read-inducing directive is
|
|
2535
|
+
// appended after the loop regardless of how many images the prompt carries (R1.2).
|
|
2536
|
+
let materializedAnyImage = false;
|
|
2178
2537
|
for (const chunk of prompt.prompt) {
|
|
2179
2538
|
// R1.3: isolate every block. A malformed block — even one whose `type` getter
|
|
2180
2539
|
// throws — is SKIPPED and RECORDED, never allowed to abort the remaining blocks.
|
|
@@ -2224,9 +2583,25 @@ export function promptToClaude(prompt, logger = console) {
|
|
|
2224
2583
|
}
|
|
2225
2584
|
break;
|
|
2226
2585
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2586
|
+
case "image": {
|
|
2587
|
+
// R1.1: an ACP image carries base64 `data` + `mimeType` (NOT `source`/`media_type`).
|
|
2588
|
+
// Materialize it to a uuid-named temp file and reference it with `@<path>` so the TUI
|
|
2589
|
+
// re-reads (and vision-encodes) it — mirroring the `resource_link` @<path> idiom. A
|
|
2590
|
+
// single Read-inducing directive is appended AFTER the loop (R1.2). If materialize
|
|
2591
|
+
// throws, the surrounding per-block try/catch isolates it (R1.3): this image is skipped.
|
|
2592
|
+
//
|
|
2593
|
+
// R1.3 (shell-safety): the path is uuid-named + fork-controlled and the prompt body reaches
|
|
2594
|
+
// the PTY via bracketed-paste (engine-pty `sendPrompt` → `p.write`), NOT the `bash -lc` spawn
|
|
2595
|
+
// string — so no shell ever parses `@<path>` and the prompt has no injection surface. extFor
|
|
2596
|
+
// maps mimeType to a CLOSED extension set, so a hostile mimeType cannot reach the filename.
|
|
2597
|
+
const tempPath = materializeImage(chunk.data, chunk.mimeType);
|
|
2598
|
+
materializedSink?.push(tempPath);
|
|
2599
|
+
fragments.push(`@${tempPath}`);
|
|
2600
|
+
materializedAnyImage = true;
|
|
2601
|
+
break;
|
|
2602
|
+
}
|
|
2603
|
+
// audio → SILENT no-op (R4.1): expected-but-unsupported media in v1. It emits no PTY
|
|
2604
|
+
// bytes and is NOT logged (it is not an error).
|
|
2230
2605
|
case "audio":
|
|
2231
2606
|
break;
|
|
2232
2607
|
default:
|
|
@@ -2243,6 +2618,11 @@ export function promptToClaude(prompt, logger = console) {
|
|
|
2243
2618
|
continue;
|
|
2244
2619
|
}
|
|
2245
2620
|
}
|
|
2621
|
+
// R1.2: exactly one Read-inducing directive per prompt when ≥1 image was materialized, so the
|
|
2622
|
+
// TUI's Read tool fires on the @<path>(s) above and vision-encodes them (the proven 2.1.195 path).
|
|
2623
|
+
if (materializedAnyImage) {
|
|
2624
|
+
fragments.push("Read the attached image(s) above and use them to answer.");
|
|
2625
|
+
}
|
|
2246
2626
|
return fragments.filter((fragment) => fragment.length > 0).join(" ");
|
|
2247
2627
|
}
|
|
2248
2628
|
/**
|
|
@@ -2300,13 +2680,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2300
2680
|
break;
|
|
2301
2681
|
case "thinking":
|
|
2302
2682
|
case "thinking_delta":
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2683
|
+
// Story 056 (#793): a signature-only thinking block (thinking.display "omitted") carries empty
|
|
2684
|
+
// text — suppress the agent_thought_chunk rather than emit an empty one (update stays null → no
|
|
2685
|
+
// push at the `if (update)` guard). A non-empty thinking block emits exactly as before.
|
|
2686
|
+
if (chunk.thinking.length > 0) {
|
|
2687
|
+
update = {
|
|
2688
|
+
sessionUpdate: "agent_thought_chunk",
|
|
2689
|
+
content: {
|
|
2690
|
+
type: "text",
|
|
2691
|
+
text: chunk.thinking,
|
|
2692
|
+
},
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2310
2695
|
break;
|
|
2311
2696
|
case "tool_use":
|
|
2312
2697
|
case "server_tool_use":
|