@lucascouts/claude-agent-tui 0.5.2 → 0.7.0

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