@lucascouts/claude-agent-tui 0.5.1 → 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.
Files changed (51) hide show
  1. package/dist/acp-agent.d.ts +187 -13
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +448 -60
  4. package/dist/agent-catalog.d.ts +96 -0
  5. package/dist/agent-catalog.d.ts.map +1 -0
  6. package/dist/agent-catalog.js +287 -0
  7. package/dist/claude-path.d.ts.map +1 -1
  8. package/dist/claude-path.js +6 -0
  9. package/dist/end-of-turn.d.ts +6 -0
  10. package/dist/end-of-turn.d.ts.map +1 -1
  11. package/dist/end-of-turn.js +8 -1
  12. package/dist/engine-lifecycle.d.ts +66 -1
  13. package/dist/engine-lifecycle.d.ts.map +1 -1
  14. package/dist/engine-lifecycle.js +43 -4
  15. package/dist/engine-pty.d.ts +70 -2
  16. package/dist/engine-pty.d.ts.map +1 -1
  17. package/dist/engine-pty.js +80 -6
  18. package/dist/gate/settings-writer.d.ts +34 -3
  19. package/dist/gate/settings-writer.d.ts.map +1 -1
  20. package/dist/gate/settings-writer.js +62 -7
  21. package/dist/image-input.d.ts +31 -0
  22. package/dist/image-input.d.ts.map +1 -0
  23. package/dist/image-input.js +79 -0
  24. package/dist/image-vision-smoke.d.ts +52 -0
  25. package/dist/image-vision-smoke.d.ts.map +1 -0
  26. package/dist/image-vision-smoke.js +111 -0
  27. package/dist/index.js +6 -0
  28. package/dist/mcp-config-writer.d.ts +61 -0
  29. package/dist/mcp-config-writer.d.ts.map +1 -0
  30. package/dist/mcp-config-writer.js +172 -0
  31. package/dist/model-catalog.d.ts +29 -2
  32. package/dist/model-catalog.d.ts.map +1 -1
  33. package/dist/model-catalog.js +50 -10
  34. package/dist/permissions/gate-wiring.d.ts +13 -1
  35. package/dist/permissions/gate-wiring.d.ts.map +1 -1
  36. package/dist/permissions/gate-wiring.js +158 -40
  37. package/dist/permissions/hook-server.d.ts +15 -0
  38. package/dist/permissions/hook-server.d.ts.map +1 -1
  39. package/dist/permissions/hook-server.js +30 -1
  40. package/dist/permissions/request-permission.d.ts +9 -0
  41. package/dist/permissions/request-permission.d.ts.map +1 -1
  42. package/dist/permissions/request-permission.js +20 -5
  43. package/dist/settings.d.ts.map +1 -1
  44. package/dist/settings.js +9 -0
  45. package/dist/tools.d.ts +10 -2
  46. package/dist/tools.d.ts.map +1 -1
  47. package/dist/tools.js +5 -1
  48. package/dist/usage.d.ts +3 -0
  49. package/dist/usage.d.ts.map +1 -1
  50. package/dist/usage.js +9 -5
  51. 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
- return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
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
- const payload = promptToClaude(params, this.logger);
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, payload, this.schedule);
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
- await this.client.sessionUpdate({
939
- sessionId: params.sessionId,
940
- update: {
941
- sessionUpdate: "current_mode_update",
942
- currentModeId: resolvedValue,
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 046 (R2.2): Probe B verdict effort has no live mid-session path, so an effort change
957
- // re-spawns in place with `--effort <level>` (idle-guarded, R3.7 failure path). On throw, the
958
- // applyConfigOptionValue below is skipped so the prior currentValue stays unchanged.
959
- await this.applyEffortChange(params.sessionId, session, resolvedValue);
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
- if (!session.interacted) {
1140
- throw new Error("Cannot switch to a re-spawn mode/effort before the first interaction (no transcript to resume yet); send a prompt first, then switch.");
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 selector's current value so re-spawning for one (mode OR effort) does not
1146
- // reset the other — the resume argv carries both flags.
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
- const effortLevel = change.effortLevel ?? this.currentEffort(session);
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); the flags flow into the flag-carrying resume argv.
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: true,
1155
- // Story 046 (R3.4 LIVE FIX): an in-place re-spawn may run before the first interaction (boot
1156
- // bypass), so DEFER discovery instead of the 2000ms fatal watchdog — see defaultStartEngine.
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
- // New PTY is live — only now retire the old one (idle ⇒ no pending turn to double-resolve) and swap.
1164
- oldEngine?.cleanup();
1165
- oldEngine?.kill();
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
+ }
1379
+ /**
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).
1395
+ */
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
+ }
1182
1426
  /**
1183
- * Story 046 (R2.2, design §7) — apply a reasoning-effort change. Probe B verdict: effort has no live
1184
- * mid-session mechanism (`--effort` is a spawn flag), so a change re-spawns in place with the flag
1185
- * (mirroring the dontAsk/bypass mode path), idle-guarded, with the R3.7 failure path and R3.8 latch.
1186
- * A no-op change applies nothing. Throwing here leaves the caller's applyConfigOptionValue unrun, so
1187
- * the prior currentValue is left unchanged on failure (R3.7).
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.
1188
1435
  */
1189
- async applyEffortChange(sessionId, session, level) {
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 effort while the session is busy (a turn is in flight or a re-spawn is underway); retry when idle");
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, { effortLevel: level });
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
- session.configOptions = buildConfigOptions(session.modes, value, session.modelInfos, currentEffort);
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
  }
@@ -1880,7 +2186,10 @@ export class ClaudeAcpAgent {
1880
2186
  parentId: entry.parentId,
1881
2187
  subagentLabel: deriveSubagentLabel(s?.lastMessages ?? [], entry.parentId) ?? "subagent",
1882
2188
  };
1883
- });
2189
+ },
2190
+ // FIX(watchdog-permission): re-arm the end-of-turn watchdog while a permission dialog is open,
2191
+ // so a slow human decision (JSONL silent meanwhile) is not mistaken for a dead turn.
2192
+ () => this.sessions[startedSessionId]?.turnDetector?.noteActivity());
1884
2193
  boundGate.bindPty(started.pty);
1885
2194
  started.pty.onExit(() => void boundGate.teardown());
1886
2195
  }
@@ -1893,7 +2202,13 @@ export class ClaudeAcpAgent {
1893
2202
  currentModeId: seededMode,
1894
2203
  availableModes,
1895
2204
  };
1896
- const configOptions = buildConfigOptions(modes, DEFAULT_MODEL_INFO.value, MODEL_CATALOG, settingsManager.getSettings().effortLevel);
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);
1897
2212
  // Runtime cwd is read from inside the JSONL (story 015); fall back to the requested host cwd
1898
2213
  // until the first transcript line carries `.cwd` (the seam may return cwd === undefined early).
1899
2214
  const runtimeCwd = started.cwd ?? params.cwd;
@@ -1908,6 +2223,13 @@ export class ClaudeAcpAgent {
1908
2223
  engine: started.engine,
1909
2224
  cancelled: false,
1910
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,
1911
2233
  sessionFingerprint: computeSessionFingerprint(params),
1912
2234
  settingsManager,
1913
2235
  accumulatedUsage: {
@@ -1918,6 +2240,7 @@ export class ClaudeAcpAgent {
1918
2240
  },
1919
2241
  modes,
1920
2242
  modelInfos: MODEL_CATALOG,
2243
+ agents,
1921
2244
  configOptions,
1922
2245
  contextWindowSize: inferContextWindowFromModel(DEFAULT_MODEL_INFO.value) ?? DEFAULT_CONTEXT_WINDOW,
1923
2246
  taskState,
@@ -1978,7 +2301,7 @@ function buildAvailableModes(modelInfo) {
1978
2301
  // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
1979
2302
  // `"default"` sentinel and `undefined` (effort option absent for the model) to
1980
2303
  // `null` ensures any previously-applied flag is actually cleared.
1981
- function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel) {
2304
+ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLevel, agents = [], currentAgent) {
1982
2305
  const options = [
1983
2306
  {
1984
2307
  id: "mode",
@@ -2022,8 +2345,13 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
2022
2345
  .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
2023
2346
  .join(" "),
2024
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 },
2025
2351
  ];
2026
- const includes = (l) => l === "default" || supportedLevels.includes(l);
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);
2027
2355
  const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
2028
2356
  options.push({
2029
2357
  id: "effort",
@@ -2035,6 +2363,27 @@ function buildConfigOptions(modes, currentModelId, modelInfos, currentEffortLeve
2035
2363
  options: effortOptions,
2036
2364
  });
2037
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
+ }
2038
2387
  return options;
2039
2388
  }
2040
2389
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
@@ -2164,14 +2513,27 @@ function filePathFromUri(uri) {
2164
2513
  * anything below the threshold — or large but path-less — is
2165
2514
  * inlined directly so the context is not lost.
2166
2515
  *
2167
- * `resource` (blob) / `image` / `audio` blocks are SILENT no-ops here (R4.1): they
2168
- * emit no PTY bytes and are NOT logged they are expected-but-unsupported media in
2169
- * v1, not errors. An UNKNOWN block `type` (the `default` branch) and any block whose
2170
- * mapping THROWS are treated as malformed: skipped, recorded via the `logger`, and the
2171
- * remaining valid blocks still map one bad block never aborts the whole prompt (R1.3).
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`.
2172
2531
  */
2173
- export function promptToClaude(prompt, logger = console) {
2532
+ export function promptToClaude(prompt, logger = console, materializedSink) {
2174
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;
2175
2537
  for (const chunk of prompt.prompt) {
2176
2538
  // R1.3: isolate every block. A malformed block — even one whose `type` getter
2177
2539
  // throws — is SKIPPED and RECORDED, never allowed to abort the remaining blocks.
@@ -2221,9 +2583,25 @@ export function promptToClaude(prompt, logger = console) {
2221
2583
  }
2222
2584
  break;
2223
2585
  }
2224
- // image / audio → SILENT no-ops (R4.1): expected-but-unsupported media in v1.
2225
- // They emit no PTY bytes and are NOT logged (they are not errors).
2226
- case "image":
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).
2227
2605
  case "audio":
2228
2606
  break;
2229
2607
  default:
@@ -2240,6 +2618,11 @@ export function promptToClaude(prompt, logger = console) {
2240
2618
  continue;
2241
2619
  }
2242
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
+ }
2243
2626
  return fragments.filter((fragment) => fragment.length > 0).join(" ");
2244
2627
  }
2245
2628
  /**
@@ -2297,13 +2680,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
2297
2680
  break;
2298
2681
  case "thinking":
2299
2682
  case "thinking_delta":
2300
- update = {
2301
- sessionUpdate: "agent_thought_chunk",
2302
- content: {
2303
- type: "text",
2304
- text: chunk.thinking,
2305
- },
2306
- };
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
+ }
2307
2695
  break;
2308
2696
  case "tool_use":
2309
2697
  case "server_tool_use":