@lucascouts/claude-agent-tui 0.1.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 (112) hide show
  1. package/LICENSE +191 -0
  2. package/NOTICE +14 -0
  3. package/README.md +50 -0
  4. package/dist/acp-agent.d.ts +594 -0
  5. package/dist/acp-agent.d.ts.map +1 -0
  6. package/dist/acp-agent.js +2139 -0
  7. package/dist/ansi-mirror.d.ts +42 -0
  8. package/dist/ansi-mirror.d.ts.map +1 -0
  9. package/dist/ansi-mirror.js +61 -0
  10. package/dist/besteffort.d.ts +44 -0
  11. package/dist/besteffort.d.ts.map +1 -0
  12. package/dist/besteffort.js +100 -0
  13. package/dist/billing/entrypoint-guard.d.ts +97 -0
  14. package/dist/billing/entrypoint-guard.d.ts.map +1 -0
  15. package/dist/billing/entrypoint-guard.js +166 -0
  16. package/dist/claude-path.d.ts +12 -0
  17. package/dist/claude-path.d.ts.map +1 -0
  18. package/dist/claude-path.js +61 -0
  19. package/dist/diff-enriched-reader.d.ts +41 -0
  20. package/dist/diff-enriched-reader.d.ts.map +1 -0
  21. package/dist/diff-enriched-reader.js +106 -0
  22. package/dist/diff-source.d.ts +104 -0
  23. package/dist/diff-source.d.ts.map +1 -0
  24. package/dist/diff-source.js +164 -0
  25. package/dist/end-of-turn.d.ts +172 -0
  26. package/dist/end-of-turn.d.ts.map +1 -0
  27. package/dist/end-of-turn.js +415 -0
  28. package/dist/engine-lifecycle.d.ts +222 -0
  29. package/dist/engine-lifecycle.d.ts.map +1 -0
  30. package/dist/engine-lifecycle.js +236 -0
  31. package/dist/engine-pty.d.ts +143 -0
  32. package/dist/engine-pty.d.ts.map +1 -0
  33. package/dist/engine-pty.js +222 -0
  34. package/dist/engine-watcher.d.ts +83 -0
  35. package/dist/engine-watcher.d.ts.map +1 -0
  36. package/dist/engine-watcher.js +173 -0
  37. package/dist/engine.d.ts +30 -0
  38. package/dist/engine.d.ts.map +1 -0
  39. package/dist/engine.js +34 -0
  40. package/dist/event-switch.d.ts +164 -0
  41. package/dist/event-switch.d.ts.map +1 -0
  42. package/dist/event-switch.js +206 -0
  43. package/dist/gate/port.d.ts +38 -0
  44. package/dist/gate/port.d.ts.map +1 -0
  45. package/dist/gate/port.js +126 -0
  46. package/dist/gate/settings-writer.d.ts +130 -0
  47. package/dist/gate/settings-writer.d.ts.map +1 -0
  48. package/dist/gate/settings-writer.js +349 -0
  49. package/dist/index.d.ts +3 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +106 -0
  52. package/dist/jsonl.d.ts +267 -0
  53. package/dist/jsonl.d.ts.map +1 -0
  54. package/dist/jsonl.js +527 -0
  55. package/dist/lib.d.ts +6 -0
  56. package/dist/lib.d.ts.map +1 -0
  57. package/dist/lib.js +5 -0
  58. package/dist/linearize.d.ts +219 -0
  59. package/dist/linearize.d.ts.map +1 -0
  60. package/dist/linearize.js +444 -0
  61. package/dist/live-diff-env.d.ts +7 -0
  62. package/dist/live-diff-env.d.ts.map +1 -0
  63. package/dist/live-diff-env.js +18 -0
  64. package/dist/live-subagent-env.d.ts +7 -0
  65. package/dist/live-subagent-env.d.ts.map +1 -0
  66. package/dist/live-subagent-env.js +19 -0
  67. package/dist/permissions/allow-inject.d.ts +67 -0
  68. package/dist/permissions/allow-inject.d.ts.map +1 -0
  69. package/dist/permissions/allow-inject.js +85 -0
  70. package/dist/permissions/deny.d.ts +60 -0
  71. package/dist/permissions/deny.d.ts.map +1 -0
  72. package/dist/permissions/deny.js +81 -0
  73. package/dist/permissions/gate-wiring.d.ts +112 -0
  74. package/dist/permissions/gate-wiring.d.ts.map +1 -0
  75. package/dist/permissions/gate-wiring.js +350 -0
  76. package/dist/permissions/hook-server.d.ts +72 -0
  77. package/dist/permissions/hook-server.d.ts.map +1 -0
  78. package/dist/permissions/hook-server.js +179 -0
  79. package/dist/permissions/permission-mode.d.ts +67 -0
  80. package/dist/permissions/permission-mode.d.ts.map +1 -0
  81. package/dist/permissions/permission-mode.js +100 -0
  82. package/dist/permissions/request-permission.d.ts +102 -0
  83. package/dist/permissions/request-permission.d.ts.map +1 -0
  84. package/dist/permissions/request-permission.js +124 -0
  85. package/dist/settings.d.ts +68 -0
  86. package/dist/settings.d.ts.map +1 -0
  87. package/dist/settings.js +182 -0
  88. package/dist/stop-reason-map.d.ts +17 -0
  89. package/dist/stop-reason-map.d.ts.map +1 -0
  90. package/dist/stop-reason-map.js +33 -0
  91. package/dist/subagent-source.d.ts +63 -0
  92. package/dist/subagent-source.d.ts.map +1 -0
  93. package/dist/subagent-source.js +132 -0
  94. package/dist/subagent-watcher.d.ts +40 -0
  95. package/dist/subagent-watcher.d.ts.map +1 -0
  96. package/dist/subagent-watcher.js +108 -0
  97. package/dist/tools.d.ts +119 -0
  98. package/dist/tools.d.ts.map +1 -0
  99. package/dist/tools.js +729 -0
  100. package/dist/usage-env.d.ts +7 -0
  101. package/dist/usage-env.d.ts.map +1 -0
  102. package/dist/usage-env.js +16 -0
  103. package/dist/usage.d.ts +54 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +53 -0
  106. package/dist/utils.d.ts +16 -0
  107. package/dist/utils.d.ts.map +1 -0
  108. package/dist/utils.js +83 -0
  109. package/dist/zed-register.d.ts +26 -0
  110. package/dist/zed-register.d.ts.map +1 -0
  111. package/dist/zed-register.js +106 -0
  112. package/package.json +79 -0
@@ -0,0 +1,2139 @@
1
+ import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
+ import { deleteSession, listSessions, } from "@anthropic-ai/claude-agent-sdk";
3
+ import { randomUUID } from "node:crypto";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import packageJson from "../package.json" with { type: "json" };
7
+ import { SettingsManager } from "./settings.js";
8
+ import { applyTaskCreate, applyTaskUpdate, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
9
+ import { nodeToWebReadable, nodeToWebWritable, unreachable } from "./utils.js";
10
+ // === SEAM(011): engine boundary — inject a temporary no-op engine so the agent
11
+ // boots without the cut SDK query() path; the real PTY engine arrives in 013–015/023.
12
+ // See SEAM-MAP.md (createSession/prompt CUT→023) and src/engine.ts. ===
13
+ import { createStubEngine } from "./engine.js";
14
+ import { createSessionEngine, spawnResumePty, SessionEngine } from "./engine-lifecycle.js";
15
+ import { createJsonlWatcher } from "./engine-watcher.js";
16
+ import { resolveWatchTarget } from "./jsonl.js";
17
+ import { linearizeTurns, readOrderedMessages, defaultGetMessages } from "./linearize.js";
18
+ import { createDiffEnrichedReader } from "./diff-enriched-reader.js";
19
+ import { sourceSubagentRows, defaultListSubagents, defaultGetSubagentMessages, hasSubagentSpawn, spawnIdsOpen, } from "./subagent-source.js";
20
+ import { createSubagentWatcher } from "./subagent-watcher.js";
21
+ import { classifyDiffSource, diffToolCallUpdate } from "./diff-source.js";
22
+ import { guardEvent } from "./billing/entrypoint-guard.js";
23
+ import { usageUpdatesFor } from "./usage.js";
24
+ import { createTurnResolver } from "./end-of-turn.js";
25
+ import { sendPrompt } from "./engine-pty.js";
26
+ import { setupSessionGate } from "./permissions/gate-wiring.js";
27
+ export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
28
+ const MAX_TITLE_LENGTH = 256;
29
+ function sanitizeTitle(text) {
30
+ // Replace newlines and collapse whitespace
31
+ const sanitized = text
32
+ .replace(/[\r\n]+/g, " ")
33
+ .replace(/\s+/g, " ")
34
+ .trim();
35
+ if (sanitized.length <= MAX_TITLE_LENGTH) {
36
+ return sanitized;
37
+ }
38
+ return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
39
+ }
40
+ const DEFAULT_CONTEXT_WINDOW = 200000;
41
+ /**
42
+ * No-op {@link IPty} stub for the Degrau-1 replay-only load path: there is no live `claude` process,
43
+ * but the session record's `pty` field is typed `IPty`. Every method is inert — teardown's `kill()`
44
+ * is a no-op and a read-only load never writes/resizes it.
45
+ */
46
+ const REPLAY_ONLY_NOOP_PTY = {
47
+ onExit: () => ({ dispose() { } }),
48
+ onData: () => ({ dispose() { } }),
49
+ resize() { },
50
+ write() { },
51
+ kill() { },
52
+ };
53
+ /**
54
+ * Production default for the {@link StartEngine} seam. Spawns the subscription `claude` TUI under a
55
+ * managed PTY engine (story 013/014), starts the read-only JSONL tail watcher (story 015) bound to
56
+ * that PTY, then locates the transcript by sessionId glob and reads the runtime cwd from INSIDE it
57
+ * (story 015 jsonl.ts; the cwd→dir encoding is irreversible, so we never decode the dir name).
58
+ *
59
+ * Fresh session: {@link createSessionEngine} spawns the PTY and generates the authoritative session
60
+ * id internally (story 013) — that id becomes the session key. Resume/fork: {@link spawnResumePty}
61
+ * reattaches to the requested id with the §5 robust-resume argv, wrapped in a {@link SessionEngine}.
62
+ *
63
+ * The watcher's `onEvent` is a no-op placeholder in Group 1 — the ACP pump that forwards new JSONL
64
+ * messages to the client is Group 2. NO SDK `query()` is reachable here.
65
+ */
66
+ export async function defaultStartEngine(args) {
67
+ // Each watcher signal triggers the caller's pump (story 023 Group 2) with the RESOLVED session id;
68
+ // the per-message payload is unused — the JSONL tail is the source, the signal just says "re-read".
69
+ // `args.onEvent` is absent in pure-spawn unit tests, so the watcher is a no-op there.
70
+ if (args.replayOnly && args.sessionId) {
71
+ // === SEAM(027): Degrau-1 read-only loadSession is REPLAY-ONLY. LOCATE the existing transcript
72
+ // for replay, but do NOT spawn `claude --resume`. A live resume would (a) re-emit the whole
73
+ // history through the tail pump (double render in the Agent Panel) and (b) run with the fork
74
+ // process cwd (≠ the session cwd), writing a duplicate-basename transcript that makes the
75
+ // sessionId glob ambiguous → resourceNotFound on the next load. A missing/ambiguous transcript
76
+ // throws here → createSession's resume catch maps it to resourceNotFound (unchanged client
77
+ // contract). No PTY, no tail watcher, no live engine — the only emission is replaySessionHistory.
78
+ const { cwd } = await resolveWatchTarget(args.sessionId, { ...args.locateOptions });
79
+ return { sessionId: args.sessionId, pty: REPLAY_ONLY_NOOP_PTY, watcher: undefined, engine: undefined, cwd };
80
+ }
81
+ if (args.resume && args.sessionId) {
82
+ // Resume/fork: reattach to the requested id with the §5 robust-resume argv, then discover the
83
+ // (already-existing) transcript by glob and tail it. The engine owns the PTY + watcher teardown.
84
+ const handle = spawnResumePty({
85
+ sessionId: args.sessionId,
86
+ cwd: args.cwd,
87
+ baseEnv: args.baseEnv,
88
+ spawn: args.spawn,
89
+ });
90
+ const { transcriptPath, cwd } = await resolveWatchTarget(args.sessionId, { ...args.locateOptions });
91
+ const watcher = createJsonlWatcher({
92
+ sessionId: args.sessionId,
93
+ transcriptPath,
94
+ dir: cwd,
95
+ onEvent: () => args.onEvent?.(args.sessionId),
96
+ });
97
+ const engine = new SessionEngine({ handle, watcher, sessions: args.sessions });
98
+ return { sessionId: args.sessionId, pty: handle.pty, watcher, engine, cwd };
99
+ }
100
+ // Fresh session: the engine spawns the PTY and generates the authoritative session id (story 013).
101
+ //
102
+ // === SEAM(028) sub-task 2.1: BACKGROUND-DEFER the fresh-path transcript discovery ===============
103
+ // The Claude Code TUI writes `<sessionId>.jsonl` only on the user's FIRST interaction, so for a
104
+ // fresh session the transcript is ABSENT at create time. The old blocking
105
+ // `await resolveWatchTarget(engine.sessionId)` therefore threw not-found after the 2000ms FATAL
106
+ // file-discovery watchdog → every new Zed session aborted (R1.1). The fix: return as soon as the
107
+ // PTY is live, discover the transcript in the BACKGROUND under `watchdogMs: Infinity` (cancellable
108
+ // via a signal), and arm the watcher + fire the first onEvent only when the transcript APPEARS.
109
+ const engine = createSessionEngine({
110
+ cwd: args.cwd,
111
+ baseEnv: args.baseEnv,
112
+ sessions: args.sessions,
113
+ spawn: args.spawn,
114
+ // Story 034 (§9): the per-session gate scratch settings, already on disk — claude reads them at
115
+ // startup, so the hook gates the FIRST tool call (blocker c). Absent → ungated (pre-034) spawn.
116
+ settingsFile: args.settingsFile,
117
+ });
118
+ // Hand the engine the cancellation handle for the background poll. STORE-ONLY here — the
119
+ // cleanup→`.abort()` wiring (so tearing a never-interacted session down cancels this dangling poll)
120
+ // is sub-task 3.1. The `signal` is threaded into resolveWatchTarget below so 3.1's abort unblocks it.
121
+ const ac = new AbortController();
122
+ engine.setPendingDiscovery(ac);
123
+ // Kick the discovery off in the BACKGROUND — do NOT await it on the create path. An unbounded
124
+ // (`watchdogMs: Infinity`) poll resolves only once the transcript materializes (R1.3). `watchdogMs:
125
+ // Infinity` is set FIRST, then `args.locateOptions` is spread (a test injects `glob`/clock here),
126
+ // then `signal: ac.signal` LAST so the internal cancellation signal always wins (a test must not
127
+ // override it). Its own try/catch keeps the unawaited promise from ever rejecting unhandled.
128
+ void (async () => {
129
+ try {
130
+ const { transcriptPath, cwd } = await resolveWatchTarget(engine.sessionId, {
131
+ watchdogMs: Infinity,
132
+ ...args.locateOptions,
133
+ signal: ac.signal,
134
+ });
135
+ // The transcript appeared (first interaction): arm the read-only tail watcher against the REAL
136
+ // resolved path, bind it to the engine so cleanup() stops it (story 014), and fire onEvent ONCE
137
+ // so the pump ingests the content already present (R1.3 — not merely "a file exists").
138
+ const watcher = createJsonlWatcher({
139
+ sessionId: engine.sessionId,
140
+ transcriptPath,
141
+ dir: cwd ?? args.cwd,
142
+ onEvent: () => args.onEvent?.(engine.sessionId),
143
+ });
144
+ engine.watcher = watcher;
145
+ args.onEvent?.(engine.sessionId);
146
+ }
147
+ catch (err) {
148
+ // Swallow ONLY the abort sentinel: the session was torn down before any interaction, so the
149
+ // never-resolving poll was cancelled — that is expected, not a fault. SURFACE everything else
150
+ // (the multi-match ambiguity fault, an IO error): defaultStartEngine has no logger, so a
151
+ // prefixed console.error is acceptable — NEVER silently drop it, or the ambiguity diagnostic is
152
+ // lost on the fresh path.
153
+ if (err?.name === "AbortError")
154
+ return;
155
+ console.error(`[acp-agent] fresh-session transcript discovery failed for ${engine.sessionId}:`, err);
156
+ }
157
+ })();
158
+ // Return IMMEDIATELY — the PTY is live; the watcher arms later, out of band. `watcher: undefined`
159
+ // until the transcript appears; the cwd falls back to the known host `args.cwd` (the inside-cwd is
160
+ // not known until the first JSONL line lands).
161
+ return { sessionId: engine.sessionId, pty: engine.pty, watcher: undefined, engine, cwd: args.cwd };
162
+ }
163
+ /** A single default Degrau-1 model entry. The TUI owns real model selection in Degrau-1; this is an
164
+ * honest non-interactive default so configOptions/modes have a coherent current model to anchor on. */
165
+ const DEGRAU1_DEFAULT_MODEL_INFO = {
166
+ value: "default",
167
+ displayName: "Default",
168
+ description: "Default model (selection is owned by the interactive TUI in Degrau-1)",
169
+ };
170
+ /** Build the static Degrau-1 model state (no SDK initializationResult). Single default model. */
171
+ function buildDegrau1Models() {
172
+ return {
173
+ availableModels: [
174
+ {
175
+ modelId: DEGRAU1_DEFAULT_MODEL_INFO.value,
176
+ name: DEGRAU1_DEFAULT_MODEL_INFO.displayName,
177
+ description: DEGRAU1_DEFAULT_MODEL_INFO.description,
178
+ },
179
+ ],
180
+ currentModelId: DEGRAU1_DEFAULT_MODEL_INFO.value,
181
+ };
182
+ }
183
+ /** Compute a stable fingerprint of the session-defining params so we can
184
+ * detect when a loadSession/resumeSession call requires tearing down and
185
+ * recreating the underlying Query process. MCP servers are sorted by name
186
+ * so that ordering differences don't trigger unnecessary recreations. */
187
+ function computeSessionFingerprint(params) {
188
+ const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
189
+ return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
190
+ }
191
+ // === SEAM(012/023): the engine binary is resolved from the user's PATH. After the 023 rewrite,
192
+ // createSession no longer passes the SDK `pathToClaudeCodeExecutable`; the PTY engine (story 013)
193
+ // spawns the subscription `claude` through the login shell (`bash -lc 'claude …'`), so it resolves
194
+ // from PATH — the same E1 keystone (experiments/DEGRAU0-RESULTS.md), via the shell rather than an
195
+ // explicit resolveClaudePath() call here. resolveClaudePath() (story 012) is retained for the
196
+ // `--cli` auth spawn in index.ts. See src/claude-path.ts, SEAM-MAP.md, IMPLEMENTACAO §3/§5. ===
197
+ function shouldHideClaudeAuth() {
198
+ return process.argv.includes("--hide-claude-auth");
199
+ }
200
+ // Bypass Permissions doesn't work if we are a root/sudo user
201
+ const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
202
+ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
203
+ // Slash commands that the SDK handles locally without replaying the user
204
+ // message and without invoking the model.
205
+ // The Claude SDK persists local slash command invocations (e.g. `/model`) and
206
+ // their output as user messages in the session transcript, wrapping the
207
+ // payload in these XML-like markers that the CLI uses for its own display.
208
+ // The live prompt loop drops them; replay must strip them too or they leak
209
+ // into the UI on session/load.
210
+ const LOCAL_COMMAND_TAG_PATTERN = /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
211
+ function stripMarkerTags(text) {
212
+ return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
213
+ }
214
+ /**
215
+ * Return user-message content with local-command marker tags removed, or
216
+ * `null` if nothing meaningful remains (caller should skip the message).
217
+ * Preserves real prose that's mixed in alongside the markers — e.g. a
218
+ * message like `<command-name>…</command-name>hi` becomes `hi`.
219
+ */
220
+ export function stripLocalCommandMetadata(content) {
221
+ if (typeof content === "string") {
222
+ const stripped = stripMarkerTags(content);
223
+ return stripped.trim() === "" ? null : stripped;
224
+ }
225
+ if (!Array.isArray(content))
226
+ return content;
227
+ const kept = [];
228
+ for (const block of content) {
229
+ if (block &&
230
+ typeof block === "object" &&
231
+ "type" in block &&
232
+ block.type === "text" &&
233
+ "text" in block &&
234
+ typeof block.text === "string") {
235
+ const stripped = stripMarkerTags(block.text);
236
+ if (stripped.trim() === "")
237
+ continue;
238
+ kept.push({ ...block, text: stripped });
239
+ }
240
+ else {
241
+ kept.push(block);
242
+ }
243
+ }
244
+ if (kept.length === 0)
245
+ return null;
246
+ return kept;
247
+ }
248
+ export function isLocalCommandMetadata(content) {
249
+ return stripLocalCommandMetadata(content) === null;
250
+ }
251
+ const PERMISSION_MODE_ALIASES = {
252
+ auto: "auto",
253
+ default: "default",
254
+ acceptedits: "acceptEdits",
255
+ dontask: "dontAsk",
256
+ plan: "plan",
257
+ bypasspermissions: "bypassPermissions",
258
+ bypass: "bypassPermissions",
259
+ };
260
+ export function resolvePermissionMode(defaultMode, logger = console) {
261
+ if (defaultMode === undefined) {
262
+ return "default";
263
+ }
264
+ if (typeof defaultMode !== "string") {
265
+ logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
266
+ return "default";
267
+ }
268
+ const normalized = defaultMode.trim().toLowerCase();
269
+ if (normalized === "") {
270
+ logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
271
+ return "default";
272
+ }
273
+ const mapped = PERMISSION_MODE_ALIASES[normalized];
274
+ if (!mapped) {
275
+ logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
276
+ return "default";
277
+ }
278
+ if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
279
+ logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
280
+ return "default";
281
+ }
282
+ return mapped;
283
+ }
284
+ /**
285
+ * Builds the label for the "Always Allow" permission option so the user can see
286
+ * the exact scope they are committing to. Uses the SDK-provided suggestions
287
+ * when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
288
+ * tool so "Always Allow" is never a blank check without disclosure.
289
+ */
290
+ export function describeAlwaysAllow(suggestions, toolName) {
291
+ if (!suggestions || suggestions.length === 0) {
292
+ return `Always Allow all ${toolName}`;
293
+ }
294
+ const ruleLabels = [];
295
+ const directories = [];
296
+ for (const update of suggestions) {
297
+ if (update.type === "addRules" && update.behavior === "allow") {
298
+ for (const rule of update.rules) {
299
+ ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
300
+ }
301
+ }
302
+ else if (update.type === "addDirectories") {
303
+ directories.push(...update.directories);
304
+ }
305
+ }
306
+ const parts = [];
307
+ if (ruleLabels.length > 0) {
308
+ parts.push(ruleLabels.join(", "));
309
+ }
310
+ if (directories.length > 0) {
311
+ parts.push(`access to ${directories.join(", ")}`);
312
+ }
313
+ if (parts.length === 0) {
314
+ return `Always Allow all ${toolName}`;
315
+ }
316
+ return `Always Allow ${parts.join(" and ")}`;
317
+ }
318
+ // Implement the ACP Agent interface
319
+ /**
320
+ * Story 034 (§9): register every assistant `tool_use` block id from one RAW JSONL message into the
321
+ * session gate's correlation map ({@link SessionGate.correlator}). Defensive walk over the reduced
322
+ * getSessionMessages shape — non-assistant rows, absent content, and id-less blocks are skipped.
323
+ * Double-registration of the SAME id (the message re-appearing in a later raw row) marks it
324
+ * duplicate, which the story-033 correlator then fails closed on — exactly the §9 id-reuse posture.
325
+ */
326
+ function registerGateToolUses(raw, gate) {
327
+ const msg = raw;
328
+ if (msg === null || typeof msg !== "object" || msg.type !== "assistant")
329
+ return;
330
+ const content = msg.message?.content;
331
+ if (!Array.isArray(content))
332
+ return;
333
+ for (const block of content) {
334
+ if (block !== null &&
335
+ typeof block === "object" &&
336
+ block.type === "tool_use" &&
337
+ typeof block.id === "string" &&
338
+ block.id.length > 0) {
339
+ gate.correlator.register(block.id);
340
+ }
341
+ }
342
+ }
343
+ export class ClaudeAcpAgent {
344
+ constructor(client, logger, engine = createStubEngine(), deps = {}) {
345
+ this.backgroundTerminals = {};
346
+ /** Live PTY-engine registry shared with the per-session engines (story 014 cleanup map). */
347
+ this.engines = new Map();
348
+ this.sessions = {};
349
+ this.client = client;
350
+ this.toolUseCache = {};
351
+ this.logger = logger ?? console;
352
+ this.engine = engine;
353
+ this.startEngine = deps.startEngine ?? defaultStartEngine;
354
+ // Story 043 (R2.1): when liveDiff is ON, the live JSONL reader is the diff-enriched reader
355
+ // (getSessionMessages + uuid→toolUseResult hydration), which restores the story-021 Edit/Write
356
+ // diff on BOTH the live pump and the session/load replay (both read this.getMessages once). The
357
+ // constructor default stays reduced (deps.liveDiff ?? false) for test determinism — the entrypoint
358
+ // (index.ts) is what defaults it ON. OFF → byte-for-byte the pre-043 reduced reader (R5.1).
359
+ this.getMessages = (deps.liveDiff ?? false)
360
+ ? createDiffEnrichedReader(deps.getMessages ?? defaultGetMessages, deps.diffEnrichOptions)
361
+ : deps.getMessages;
362
+ this.listSubagents = deps.listSubagents ?? defaultListSubagents;
363
+ this.getSubagentMessages = deps.getSubagentMessages ?? defaultGetSubagentMessages;
364
+ this.usageUpdate = deps.usageUpdate ?? false;
365
+ // Story 044 (R4.1): live sub-agent watcher — bootstrap-resolved (index.ts: ON unless
366
+ // FORK_LIVE_SUBAGENT_WATCH=0/false); OFF at this seam so directly-constructed test agents arm
367
+ // no 2nd watcher unless they opt in (R4.2: OFF → byte-for-byte today's pull-only path).
368
+ this.liveSubagentWatch = deps.liveSubagentWatch ?? false;
369
+ this.schedule =
370
+ deps.schedule ??
371
+ ((fn, ms) => {
372
+ const id = setTimeout(fn, ms);
373
+ return () => clearTimeout(id);
374
+ });
375
+ // Story 031 (R1.2): conservative default escalation window; tests inject their own via deps.
376
+ this.cancelEscalationMs = deps.cancelEscalationMs ?? 1000;
377
+ // Story 034 (§9): hybrid gate wiring — bootstrap-resolved (index.ts: ON unless FORK_GATE=off);
378
+ // OFF at this seam so directly-constructed test agents spin no gate unless they opt in.
379
+ this.gateEnabled = deps.gate ?? false;
380
+ this.gateOptions = deps.gateOptions;
381
+ }
382
+ async initialize(request) {
383
+ this.clientCapabilities = request.clientCapabilities;
384
+ // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
385
+ // Only offered when the client advertises `auth._meta.gateway` capability.
386
+ const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
387
+ const gatewayAuthMethod = {
388
+ id: "gateway",
389
+ name: "Custom model gateway",
390
+ description: "Use a custom gateway to authenticate and access models",
391
+ _meta: {
392
+ gateway: {
393
+ protocol: "anthropic",
394
+ },
395
+ },
396
+ };
397
+ const gatewayBedrockAuthMethod = {
398
+ id: "gateway-bedrock",
399
+ name: "Custom model gateway",
400
+ description: "Use a custom gateway to authenticate and access models",
401
+ _meta: {
402
+ gateway: {
403
+ protocol: "bedrock",
404
+ },
405
+ },
406
+ };
407
+ const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
408
+ const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
409
+ // Detect remote environments where the OAuth browser redirect to localhost
410
+ // won't work. This matches the SDK's internal isRemote check. In these cases,
411
+ // the `auth login` subcommand would fall back to a device-code-like manual
412
+ // flow, which doesn't work well over ACP, so we offer the TUI login instead.
413
+ const isRemote = !!(process.env.NO_BROWSER ||
414
+ process.env.SSH_CONNECTION ||
415
+ process.env.SSH_CLIENT ||
416
+ process.env.SSH_TTY ||
417
+ process.env.CLAUDE_CODE_REMOTE);
418
+ const terminalAuthMethods = [];
419
+ if (isRemote) {
420
+ const remoteLoginMethod = {
421
+ description: "Run `claude /login` in the terminal",
422
+ name: "Log in with Claude",
423
+ id: "claude-login",
424
+ type: "terminal",
425
+ args: ["--cli"],
426
+ };
427
+ if (supportsMetaTerminalAuth) {
428
+ remoteLoginMethod._meta = {
429
+ "terminal-auth": {
430
+ command: process.execPath,
431
+ args: [...process.argv.slice(1), "--cli"],
432
+ label: "Claude Login",
433
+ },
434
+ };
435
+ }
436
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
437
+ terminalAuthMethods.push(remoteLoginMethod);
438
+ }
439
+ }
440
+ else {
441
+ const claudeLoginMethod = {
442
+ description: "Use Claude subscription ",
443
+ name: "Claude Subscription",
444
+ id: "claude-ai-login",
445
+ type: "terminal",
446
+ args: ["--cli", "auth", "login", "--claudeai"],
447
+ };
448
+ const consoleLoginMethod = {
449
+ description: "Use Anthropic Console (API usage billing)",
450
+ name: "Anthropic Console",
451
+ id: "console-login",
452
+ type: "terminal",
453
+ args: ["--cli", "auth", "login", "--console"],
454
+ };
455
+ if (supportsMetaTerminalAuth) {
456
+ const baseArgs = process.argv.slice(1);
457
+ claudeLoginMethod._meta = {
458
+ "terminal-auth": {
459
+ command: process.execPath,
460
+ args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
461
+ label: "Claude Login",
462
+ },
463
+ };
464
+ consoleLoginMethod._meta = {
465
+ "terminal-auth": {
466
+ command: process.execPath,
467
+ args: [...baseArgs, "--cli", "auth", "login", "--console"],
468
+ label: "Anthropic Console Login",
469
+ },
470
+ };
471
+ }
472
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
473
+ terminalAuthMethods.push(claudeLoginMethod);
474
+ }
475
+ if (supportsTerminalAuth || supportsMetaTerminalAuth) {
476
+ terminalAuthMethods.push(consoleLoginMethod);
477
+ }
478
+ }
479
+ return {
480
+ protocolVersion: 1,
481
+ agentCapabilities: {
482
+ _meta: {
483
+ claudeCode: {
484
+ promptQueueing: true,
485
+ },
486
+ },
487
+ promptCapabilities: {
488
+ image: true,
489
+ embeddedContext: true,
490
+ },
491
+ mcpCapabilities: {
492
+ http: true,
493
+ sse: true,
494
+ },
495
+ loadSession: true,
496
+ sessionCapabilities: {
497
+ additionalDirectories: {},
498
+ close: {},
499
+ delete: {},
500
+ fork: {},
501
+ list: {},
502
+ resume: {},
503
+ },
504
+ },
505
+ agentInfo: {
506
+ name: packageJson.name,
507
+ title: "Claude Agent TUI",
508
+ version: packageJson.version,
509
+ },
510
+ authMethods: [
511
+ ...terminalAuthMethods,
512
+ ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
513
+ ],
514
+ };
515
+ }
516
+ async newSession(params) {
517
+ const response = await this.createSession(params, {
518
+ // Revisit these meta values once we support resume
519
+ resume: params._meta?.claudeCode?.options?.resume,
520
+ });
521
+ // Needs to happen after we return the session
522
+ setTimeout(() => {
523
+ this.sendAvailableCommandsUpdate(response.sessionId);
524
+ }, 0);
525
+ return response;
526
+ }
527
+ async unstable_forkSession(params) {
528
+ const response = await this.createSession({
529
+ cwd: params.cwd,
530
+ mcpServers: params.mcpServers ?? [],
531
+ additionalDirectories: params.additionalDirectories,
532
+ _meta: params._meta,
533
+ }, {
534
+ resume: params.sessionId,
535
+ forkSession: true,
536
+ });
537
+ // Needs to happen after we return the session
538
+ setTimeout(() => {
539
+ this.sendAvailableCommandsUpdate(response.sessionId);
540
+ }, 0);
541
+ return response;
542
+ }
543
+ async resumeSession(params) {
544
+ const result = await this.getOrCreateSession(params);
545
+ // Needs to happen after we return the session
546
+ setTimeout(() => {
547
+ this.sendAvailableCommandsUpdate(params.sessionId);
548
+ }, 0);
549
+ return result;
550
+ }
551
+ async loadSession(params) {
552
+ // Degrau-1 read-only: replay-only — locate the transcript and replay it, but do NOT spawn a live
553
+ // `claude --resume` (story 027 live regression: a live resume re-emits the history through the
554
+ // tail pump → double render, and writes a wrong-cwd duplicate transcript → ambiguous glob).
555
+ const result = await this.getOrCreateSession(params, { replayOnly: true });
556
+ await this.replaySessionHistory(params.sessionId);
557
+ // Send available commands after replay so it doesn't interleave with history
558
+ setTimeout(() => {
559
+ this.sendAvailableCommandsUpdate(params.sessionId);
560
+ }, 0);
561
+ return result;
562
+ }
563
+ async listSessions(params) {
564
+ const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
565
+ const sessions = [];
566
+ for (const session of sdk_sessions) {
567
+ if (!session.cwd)
568
+ continue;
569
+ sessions.push({
570
+ sessionId: session.sessionId,
571
+ cwd: session.cwd,
572
+ title: sanitizeTitle(session.summary),
573
+ updatedAt: new Date(session.lastModified).toISOString(),
574
+ });
575
+ }
576
+ return {
577
+ sessions,
578
+ };
579
+ }
580
+ async authenticate(_params) {
581
+ if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
582
+ this.gatewayAuthRequest = _params;
583
+ return;
584
+ }
585
+ throw new Error("Method not implemented.");
586
+ }
587
+ async prompt(params) {
588
+ const sessionRecord = this.sessions[params.sessionId];
589
+ if (!sessionRecord) {
590
+ throw new Error("Session not found");
591
+ }
592
+ // Story 034 (R2.3 fix): a prompt into a dead engine must fail FAST and legibly. sendPrompt is
593
+ // post-exit-safe (story 014), so without this guard the write would silently no-op and the turn
594
+ // would hang until the stall watchdog — exactly the orphaned-prompt failure the G2 live run hit.
595
+ if (sessionRecord.engine?.isDisposed) {
596
+ throw new Error(`session engine disposed — the claude PTY for session ${params.sessionId} has exited; ` +
597
+ "reload or recreate the session to continue");
598
+ }
599
+ // === SEAM(030) — Degrau-2 ACP-side input (R1, R1.2, R5, R5.1, R5.2). The Degrau-1 read-only
600
+ // no-op is replaced by the real prompt loop: assemble the §8 PTY payload, submit it, and resolve
601
+ // the pending `session/prompt` SOLELY through the story-024 end-of-turn detector — never by this
602
+ // writer guessing completion (R5.1). The §10 entrypoint=='cli' billing guard-rail in pumpUpdates
603
+ // stays untouched: enabling input does not weaken it.
604
+ //
605
+ // Everything from `promptToClaude` through `sendPrompt` and `turnDetector = detector` runs
606
+ // SYNCHRONOUSLY before the `await promise`, so the PTY write is committed (and the detector is
607
+ // reachable by the live pump and the cancel path) the instant the turn begins.
608
+ // (1) Assemble the PTY text payload from the ContentBlock[] (Task 1 rewrote this to return text).
609
+ const payload = promptToClaude(params, this.logger);
610
+ // (2) Register the turn with the story-024 resolver: the detector that the live pump feeds, and
611
+ // the awaitable that settles ONCE with { stopReason: mapStopReason(...) } on the terminal
612
+ // boundary (or rejects on the watchdog). One shared `schedule` drives sendPrompt + the resolver.
613
+ const { detector, promise, cancel } = createTurnResolver({
614
+ schedule: this.schedule,
615
+ sessionId: params.sessionId,
616
+ logger: this.logger,
617
+ });
618
+ sessionRecord.turnDetector = detector;
619
+ sessionRecord.turnCancel = cancel;
620
+ detector.beginTurn();
621
+ // (3) Submit with the §8 convention (single-line: write→delayed \r; multi-line: bracketed-paste).
622
+ // On a PTY-write failure, reject the pending prompt via the throw — markCancelled clears the
623
+ // detector's Δt + watchdog timers so nothing is left hung — rather than swallowing the error.
624
+ try {
625
+ sendPrompt(sessionRecord.pty, payload, this.schedule);
626
+ }
627
+ catch (e) {
628
+ detector.markCancelled();
629
+ sessionRecord.turnDetector = undefined;
630
+ sessionRecord.turnCancel = undefined;
631
+ throw e;
632
+ }
633
+ // (4) Resolve ONLY via the detector's terminal boundary. The pump feeds raw JSONL messages to
634
+ // `sessionRecord.turnDetector`; this method emits NO `client.sessionUpdate` (the pump owns that).
635
+ try {
636
+ return await promise;
637
+ }
638
+ finally {
639
+ sessionRecord.turnDetector = undefined;
640
+ sessionRecord.turnCancel = undefined;
641
+ // Story 044 (R2.3): the turn is over — resolved OR cancelled, both settle this same promise —
642
+ // so the in-turn sub-agent watcher dies with it (covers turn-resolve AND markCancelled paths).
643
+ sessionRecord.subagentWatcher?.stop();
644
+ sessionRecord.subagentWatcher = undefined;
645
+ }
646
+ }
647
+ async cancel(params) {
648
+ const sessionRecord = this.sessions[params.sessionId];
649
+ if (!sessionRecord) {
650
+ return;
651
+ }
652
+ // === SEAM(023→031): the SDK `query.interrupt()` body is REMOVED. Cancel is re-implemented
653
+ // against the PTY (§3 CORTAR), in two halves: (Task 1) RESOLUTION via the story-024 latch, then
654
+ // (Task 2) the PTY interrupt ESCALATION via the story-014 primitives. ===
655
+ // Task 1 (R2.1, R2.2, R3.1): resolve the in-flight prompt as 'cancelled' via the story-024 latch
656
+ // (the resolver claims the latch + calls markCancelled). No-op when no turn is in flight.
657
+ const hadInFlightTurn = sessionRecord.turnCancel !== undefined;
658
+ sessionRecord.cancelled = true;
659
+ sessionRecord.turnCancel?.();
660
+ // Task 2 (R1.1, R1.2, R1.3) — revised by the story-034 live acceptance (R2.3): best-effort STOP
661
+ // the underlying claude TUI via the story-014 PTY primitives. Only when a turn was actually in
662
+ // flight (R1.3: no-op with no turn) AND the engine is still alive (R1.3: inert after PTY exit).
663
+ // Ctrl+C first; Esc only if the PTY has not exited within a short LOCAL window. The ladder ENDS
664
+ // at Esc: the G2 live run (sessions 22e2672c/6262610a) proved the TUI aborts the turn on Ctrl+C
665
+ // WITHOUT exiting, so `isDisposed` can never read as "yielded" on a live session — the former
666
+ // p.kill() rung therefore killed EVERY live cancelled session and orphaned the next prompt
667
+ // (an R2.3 violation; §8 asked only for \x03). kill() stays a teardown concern, never a cancel
668
+ // rung; a genuinely zombie TUI is surfaced by the next turn's stall watchdog and removed by
669
+ // teardown. Each primitive is itself post-exit-safe (story 014), so the isDisposed guards are
670
+ // belt-and-suspenders against writing to a dead handle.
671
+ if (!hadInFlightTurn) {
672
+ return;
673
+ }
674
+ const engine = sessionRecord.engine;
675
+ if (!engine || engine.isDisposed) {
676
+ return;
677
+ }
678
+ engine.interrupt(); // \x03 (Ctrl+C), synchronously, before any escalation
679
+ this.schedule(() => {
680
+ if (engine.isDisposed)
681
+ return; // PTY exited meanwhile → nothing left to escalate to
682
+ engine.escape(); // \x1b (Esc) — a no-op on an idle TUI; the ladder ends here
683
+ }, this.cancelEscalationMs);
684
+ }
685
+ /** Cleanly tear down a session: cancel in-flight work, dispose resources,
686
+ * and remove it from the session map. */
687
+ async teardownSession(sessionId) {
688
+ const session = this.sessions[sessionId];
689
+ if (!session) {
690
+ return;
691
+ }
692
+ await this.cancel({ sessionId });
693
+ // Story 044 (R2.3): stop the sub-agent watcher on teardown — idempotent with the prompt-finally stop.
694
+ session.subagentWatcher?.stop();
695
+ session.subagentWatcher = undefined;
696
+ session.settingsManager.dispose();
697
+ // === SEAM(023) Group 1: tear down via the engine handle (cleanup/kill), never the SDK Query
698
+ // (story 014) idempotently kills the PTY and stops the JSONL watcher; if no engine handle is
699
+ // present (e.g. an injected fake), fall back to stopping the watcher directly. ===
700
+ if (session.engine) {
701
+ session.engine.cleanup();
702
+ session.engine.kill();
703
+ }
704
+ else {
705
+ // `watcher` is now OPTIONAL (story 028, sub-task 2.1): a fresh session may be torn down before
706
+ // its transcript appeared, so no watcher was ever armed — null-guard the no-engine fallback.
707
+ // (The 3.1 task text attributes this guard to 3.1; the type-widening in 2.1 forces it here so
708
+ // the build stays green. 3.1 adds the cleanup→discovery-abort logic and its own tests.)
709
+ session.watcher?.stop();
710
+ }
711
+ // Story 034 (§9): dispose the per-session permission gate AFTER the PTY is gone (no live claude
712
+ // can fire a hook into the closing server): close the hook server (bounded — never hangs on an
713
+ // in-flight decider) and restore/delete the scratch settings. Idempotent with the PTY onExit
714
+ // teardown hook, so a crashed-TUI session that already tore the gate down is a no-op here.
715
+ if (session.gate) {
716
+ await session.gate.teardown();
717
+ }
718
+ this.engines.delete(sessionId);
719
+ delete this.sessions[sessionId];
720
+ }
721
+ /** Tear down all active sessions. Called when the ACP connection closes. */
722
+ async dispose() {
723
+ await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
724
+ }
725
+ async closeSession(params) {
726
+ if (!this.sessions[params.sessionId]) {
727
+ throw new Error("Session not found");
728
+ }
729
+ await this.teardownSession(params.sessionId);
730
+ return {};
731
+ }
732
+ async unstable_deleteSession(params) {
733
+ // Tear down any active in-memory state first so the on-disk file isn't
734
+ // recreated by an outstanding query writing to it.
735
+ if (this.sessions[params.sessionId]) {
736
+ await this.teardownSession(params.sessionId);
737
+ }
738
+ await deleteSession(params.sessionId);
739
+ return {};
740
+ }
741
+ async unstable_setSessionModel(params) {
742
+ const session = this.sessions[params.sessionId];
743
+ if (!session) {
744
+ throw new Error("Session not found");
745
+ }
746
+ // Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
747
+ // downstream lookups in modelInfos succeed and the effort option isn't
748
+ // silently dropped.
749
+ const resolved = resolveModelPreference(session.modelInfos, params.modelId);
750
+ const modelId = resolved?.value ?? params.modelId;
751
+ // === SEAM(023) Group 1: read-only Degrau-1 shim — update local model state + emit the ACP
752
+ // config_option_update notification only. No SDK `query.setModel`. The interactive TUI owns
753
+ // real model selection in Degrau-1.
754
+ // Degrau 2 (030/032): PTY-backed control — drive the TUI to switch models. ===
755
+ await this.updateConfigOption(params.sessionId, "model", modelId);
756
+ }
757
+ async setSessionMode(params) {
758
+ if (!this.sessions[params.sessionId]) {
759
+ throw new Error("Session not found");
760
+ }
761
+ await this.applySessionMode(params.sessionId, params.modeId);
762
+ await this.updateConfigOption(params.sessionId, "mode", params.modeId);
763
+ return {};
764
+ }
765
+ async setSessionConfigOption(params) {
766
+ const session = this.sessions[params.sessionId];
767
+ if (!session) {
768
+ throw new Error("Session not found");
769
+ }
770
+ if (typeof params.value !== "string") {
771
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
772
+ }
773
+ const option = session.configOptions.find((o) => o.id === params.configId);
774
+ if (!option) {
775
+ throw new Error(`Unknown config option: ${params.configId}`);
776
+ }
777
+ const allValues = "options" in option && Array.isArray(option.options)
778
+ ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
779
+ : [];
780
+ let validValue = allValues.find((o) => o.value === params.value);
781
+ // For model options, fall back to resolveModelPreference when the exact
782
+ // value doesn't match. This lets callers use human-friendly aliases like
783
+ // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
784
+ if (!validValue && params.configId === "model") {
785
+ const modelInfos = allValues.map((o) => ({
786
+ value: o.value,
787
+ displayName: o.name,
788
+ description: o.description ?? "",
789
+ }));
790
+ const resolved = resolveModelPreference(modelInfos, params.value);
791
+ if (resolved) {
792
+ validValue = allValues.find((o) => o.value === resolved.value);
793
+ }
794
+ }
795
+ if (!validValue) {
796
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
797
+ }
798
+ // Use the canonical option value so downstream code always receives the
799
+ // model ID rather than the caller-supplied alias.
800
+ const resolvedValue = validValue.value;
801
+ if (params.configId === "mode") {
802
+ await this.applySessionMode(params.sessionId, resolvedValue);
803
+ await this.client.sessionUpdate({
804
+ sessionId: params.sessionId,
805
+ update: {
806
+ sessionUpdate: "current_mode_update",
807
+ currentModeId: resolvedValue,
808
+ },
809
+ });
810
+ }
811
+ // === SEAM(023) Group 1: the `model` branch's SDK `query.setModel` is dropped — local config
812
+ // state is updated by applyConfigOptionValue below (read-only Degrau-1 shim).
813
+ // Degrau 2 (030/032): PTY-backed control. ===
814
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
815
+ return { configOptions: session.configOptions };
816
+ }
817
+ async applySessionMode(sessionId, modeId) {
818
+ switch (modeId) {
819
+ case "auto":
820
+ case "default":
821
+ case "acceptEdits":
822
+ case "bypassPermissions":
823
+ case "dontAsk":
824
+ case "plan":
825
+ break;
826
+ default:
827
+ throw new Error("Invalid Mode");
828
+ }
829
+ const session = this.sessions[sessionId];
830
+ if (!session) {
831
+ throw new Error("Session not found");
832
+ }
833
+ if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
834
+ throw new Error(`Mode ${modeId} is not available in this session`);
835
+ }
836
+ // === SEAM(023) Group 1: read-only Degrau-1 shim — validate the mode against local availableModes
837
+ // above; the local currentModeId is updated by applyConfigOptionValue and the notification is
838
+ // emitted by the caller. No SDK `query.setPermissionMode`.
839
+ // Degrau 2 (030/032): PTY-backed control — drive the TUI to apply the permission mode. ===
840
+ }
841
+ async replaySessionHistory(sessionId) {
842
+ const session = this.sessions[sessionId];
843
+ if (!session)
844
+ return; // load was torn down before replay (defensive; loadSession just created it)
845
+ // Read via the SAME seam + ordering the live tail pump uses (readOrderedMessages → getSessionMessages,
846
+ // then linearizeTurns), so a LOADED thread orders identically to a LIVE one (story 026 R4.1/R4.2) and
847
+ // an R1.3 SDK-drift error is surfaced loudly rather than swallowed.
848
+ const messages = await readOrderedMessages(sessionId, session.cwd, {
849
+ getMessages: this.getMessages,
850
+ });
851
+ // Emit through the SHARED source+merge+linearize+emit loop the live pump runs — top-level turns
852
+ // (toAcpNotifications + structuredPatch diff + optional usage_update) AND the nested sub-agent rows
853
+ // (story 041) on their spawning Task id. Factoring this single loop is what GUARANTEES loaded == live
854
+ // with no replay-only divergence (R3.2): the replay path cannot drift from the pump because it IS the
855
+ // pump's loop (the same lesson as the story-026 diff and story-038 usage moves into the shared emit).
856
+ //
857
+ // The story-027 anti-double-emit seeding is INHERENT to that loop: it adds each emitted top-level
858
+ // turn's uuid to `session.emitted` and each emitted nested row's uuid to `session.emittedNested`, so a
859
+ // tail pump armed by a resumed/loaded session re-reads the SAME transcript and emits NOTHING new (the
860
+ // gates already hold every replayed uuid). We therefore do NOT pre-seed `emitted` before the loop —
861
+ // doing so would make the loop's own `emitted` gate SUPPRESS replay's main-turn rendering.
862
+ await this.emitLinearizedWithNested(sessionId, session, messages);
863
+ }
864
+ /**
865
+ * Shared per-turn ACP emission used by BOTH the `session/load` replay ({@link replaySessionHistory})
866
+ * and the live tail pump ({@link pumpUpdates}). Emits the message's `toAcpNotifications` updates with
867
+ * `registerHooks:false`; when the turn carries a `toolUseResult`, the story-021 structuredPatch diff
868
+ * (`{type:'diff'}`) attached to the open tool call; and finally the optional, default-OFF UNSTABLE
869
+ * `usage_update` (story 025) with its R8 per-session reject latch. Factoring all three here is what
870
+ * guarantees a LOADED thread renders byte-for-byte like a LIVE one (story 026 R3.3/R4.2): the
871
+ * validate-026 gap was the diff-emission block living only in pumpUpdates, so a replay-only load (no
872
+ * live pump) rendered Edit/Write WITHOUT a diff; story 038 moved usage_update HERE for the SAME
873
+ * reason (loaded==live for usage). Only dedup and the billing guard-rail stay with the callers.
874
+ */
875
+ async emitTurnUpdates(sessionId, turn, toolUseCache) {
876
+ const session = this.sessions[sessionId];
877
+ const source = turn.message;
878
+ const role = source.message?.role;
879
+ let content = source.message?.content;
880
+ if (role === "user") {
881
+ content = stripLocalCommandMetadata(content);
882
+ // Pure command-metadata payloads strip to null — nothing to render.
883
+ if (content === null)
884
+ return;
885
+ }
886
+ for (const notification of toAcpNotifications(
887
+ // @ts-expect-error - message.content/role are untyped in the reduced SDK shape
888
+ content, role, sessionId, toolUseCache, this.client, this.logger, {
889
+ registerHooks: false,
890
+ clientCapabilities: this.clientCapabilities,
891
+ cwd: session?.cwd,
892
+ taskState: session?.taskState,
893
+ })) {
894
+ await this.client.sessionUpdate(notification);
895
+ }
896
+ // Edit/Write diffs (story 021/026): the SDK PostToolUse hook that produced diffs is GONE in the PTY
897
+ // path, so source the diff DIRECTLY from the JSONL `toolUseResult` (structuredPatch + originalFile /
898
+ // content) and emit a `tool_call_update` attached to the already-open tool call (story 019 seam).
899
+ // No-op when the message carries no `toolUseResult` (e.g. the getSessionMessages reduced shape — see
900
+ // the getsessionmessages-reduced-shape follow-up) or the tool is not a renderable Edit/Write. The
901
+ // tool NAME is recovered from the per-pass/per-session toolUseCache.
902
+ const toolUseResult = turn.message.toolUseResult;
903
+ if (toolUseResult !== undefined && Array.isArray(content)) {
904
+ for (const block of content) {
905
+ if (block !== null &&
906
+ typeof block === "object" &&
907
+ block.type === "tool_result" &&
908
+ typeof block.tool_use_id === "string") {
909
+ const toolCallId = block.tool_use_id;
910
+ const name = toolUseCache[toolCallId]?.name;
911
+ const diffUpdate = diffToolCallUpdate(classifyDiffSource(name, toolUseResult), toolCallId);
912
+ if (diffUpdate) {
913
+ await this.client.sessionUpdate({
914
+ sessionId,
915
+ update: diffUpdate,
916
+ });
917
+ }
918
+ }
919
+ }
920
+ }
921
+ // Story 025 (R3.1/R3.2): optional UNSTABLE usage_update, gated OFF by default. A no-op unless
922
+ // the usageUpdate flag is ON and the message carries usage tokens; `size` comes from the
923
+ // session's context window. The flag stays OFF until the live-Zed acceptance probe (Task 3.3).
924
+ // Story 025 (R3.3, R8): once the client rejects a usage_update, latch it off for the session
925
+ // and never re-throw — the surrounding text/thinking/tool-call stream must keep flowing.
926
+ // Story 038: emitted HERE (after toAcpNotifications + diff) — i.e. trailing the turn's content
927
+ // exactly as the live pump did — so a LOADED thread carries usage_update byte-for-byte like a
928
+ // LIVE one (symmetric to the story-026 diff move; the validate-038 gap was that usage lived only
929
+ // in pumpUpdates, so the replay-only load never emitted it).
930
+ if (session && !session.usageDisabled) {
931
+ const carrier = turn.message.message ?? {};
932
+ for (const usageUpdate of usageUpdatesFor(carrier, {
933
+ usageUpdate: this.usageUpdate,
934
+ contextWindowSize: session.contextWindowSize,
935
+ })) {
936
+ try {
937
+ await this.client.sessionUpdate({
938
+ sessionId,
939
+ update: usageUpdate,
940
+ });
941
+ }
942
+ catch (err) {
943
+ // R8 detection: an ACP client error on the UNSTABLE notification. Latch off, log once
944
+ // for drift telemetry, and stop — never propagate the rejection into the turn loop.
945
+ session.usageDisabled = true;
946
+ this.logger.error(`usage_update rejected by client (R8) — suppressing further usage_update for session ${sessionId}: ${String(err)}`);
947
+ break;
948
+ }
949
+ }
950
+ }
951
+ }
952
+ /**
953
+ * Story 041 (R2.1, R2.2) — build the ACP `tool_call_update`(s) that render ONE nested sub-agent
954
+ * row UNDER its spawning Task tool_call. The nested row's `parent_tool_use_id` is the SPAWNING
955
+ * Task's `tool_use.id`; Zed merges `tool_call_update`s by `tool_call_id` and APPENDS their content
956
+ * (ZED-CLIENT-STUDY §Q2 ev.6: tool calls correlate by id with field-by-field merge, `ContentBlock::append`
957
+ * is pure concatenation), so emitting on the parent id nests the sub-agent's output under the Task.
958
+ *
959
+ * The block→content mapping is NOT re-implemented here: it REUSES the story-018/019/020 translator
960
+ * `toAcpNotifications` (text → agent_message_chunk, thinking → agent_thought_chunk, the sub-agent's
961
+ * own tool_use → tool_call, its tool_result → tool_call_update). We then RE-TARGET that translated
962
+ * output as `ToolCallContent` items on the PARENT id. Consequently the sub-agent's own tool_use /
963
+ * tool_result render as summarized markdown content WITHIN the parent Task tool_call (the tool's
964
+ * title/translated content), NOT as separate top-level `tool_call`s.
965
+ *
966
+ * Returns `[]` (no emission) when `parent_tool_use_id` is missing/null — there is no parent to nest
967
+ * under (a non-sidechain or malformed row). The caller does the dedup / arrival ordering (story 017's
968
+ * uuid-sorted `Turn.nested`); this is a pure builder.
969
+ */
970
+ nestedUpdatesFor(sessionId, message, toolUseCache) {
971
+ const parentId = message.parent_tool_use_id;
972
+ // No spawning Task → nothing to nest under. R2: only sidechain rows (those claimed by a Task
973
+ // tool_use) render nested; a row without a parent id is not one.
974
+ if (typeof parentId !== "string" || parentId.length === 0)
975
+ return [];
976
+ const session = this.sessions[sessionId];
977
+ const source = message.message;
978
+ const role = source?.role === "user" ? "user" : "assistant";
979
+ const content = source?.content;
980
+ // No content blocks to translate (reduced shape may omit them) → nothing to emit.
981
+ if (content === undefined || content === null)
982
+ return [];
983
+ // REUSE the 018/019/020 translators. `registerHooks:false` — replay/nested emission must not arm
984
+ // live PostToolUse hooks (same contract as emitTurnUpdates). The sub-agent's own tool_use/tool_result
985
+ // pass through their OWN cache so the translator's tool_call→tool_call_update lifecycle is internally
986
+ // consistent; we discard the cache afterwards (it is never the parent's cache — the sub-agent's ids
987
+ // are not surfaced top-level).
988
+ const translated = toAcpNotifications(
989
+ // @ts-expect-error - message.content/role are untyped in the reduced SDK shape
990
+ content, role, sessionId, toolUseCache, this.client, this.logger, {
991
+ registerHooks: false,
992
+ clientCapabilities: this.clientCapabilities,
993
+ cwd: session?.cwd,
994
+ taskState: session?.taskState,
995
+ });
996
+ // Re-target every translated update as `ToolCallContent` on the PARENT id. A `tool_call_update`
997
+ // replaces the content collection (ToolCallUpdate.content semantics), and Zed APPENDS across
998
+ // successive updates by id — so emit one nesting `tool_call_update` per translated update to
999
+ // preserve arrival order without clobbering earlier nested content.
1000
+ const out = [];
1001
+ for (const notification of translated) {
1002
+ const nestedContent = this.toNestedContent(notification.update);
1003
+ if (nestedContent.length === 0)
1004
+ continue;
1005
+ out.push({
1006
+ sessionId,
1007
+ update: {
1008
+ sessionUpdate: "tool_call_update",
1009
+ toolCallId: parentId,
1010
+ content: nestedContent,
1011
+ _meta: { claudeCode: { parentToolUseId: parentId } },
1012
+ },
1013
+ });
1014
+ }
1015
+ return out;
1016
+ }
1017
+ /**
1018
+ * Story 041 (R2.2) — fold one translated nested update into `ToolCallContent[]` for nesting under
1019
+ * the parent Task. Message/thought chunks contribute their ContentBlock directly. The sub-agent's
1020
+ * OWN tool_use/tool_result (a `tool_call`/`tool_call_update` from the translator) are SUMMARIZED as
1021
+ * markdown content within the parent — never re-emitted as a separate top-level tool_call: we take
1022
+ * the tool's human-readable `title` (a bold markdown line) plus any `content` the story-019 translator
1023
+ * already produced. `plan` and other non-content updates carry nothing renderable as nested content
1024
+ * and are dropped.
1025
+ */
1026
+ toNestedContent(update) {
1027
+ const u = update;
1028
+ switch (u.sessionUpdate) {
1029
+ case "agent_message_chunk":
1030
+ case "user_message_chunk":
1031
+ case "agent_thought_chunk":
1032
+ // `content` here is a single ContentBlock ({type:"text",text} etc.) — wrap it as ToolCallContent.
1033
+ return u.content ? [{ type: "content", content: u.content }] : [];
1034
+ case "tool_call":
1035
+ case "tool_call_update": {
1036
+ // The sub-agent's own tool — summarize as markdown WITHIN the parent. Bold the tool title
1037
+ // (e.g. the Bash command surfaced by toolInfoFromToolUse) and append any translated content
1038
+ // (e.g. a tool_use's `prompt`, or a tool_result's rendered output).
1039
+ const acc = [];
1040
+ if (typeof u.title === "string" && u.title.length > 0) {
1041
+ acc.push({ type: "content", content: { type: "text", text: `**${u.title}**` } });
1042
+ }
1043
+ if (Array.isArray(u.content)) {
1044
+ for (const item of u.content)
1045
+ acc.push(item);
1046
+ }
1047
+ return acc;
1048
+ }
1049
+ default:
1050
+ // plan / current_mode_update / usage_update / … — not renderable as nested content.
1051
+ return [];
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Tail-driven update pump (story 023 Groups 2-3). Fired on each watcher signal: re-reads the
1056
+ * transcript LIVE via getSessionMessages (E5 REUSE-live, billing-free), linearizes via story 017,
1057
+ * and emits each not-yet-emitted turn through the reused `toAcpNotifications` (Edit/Write diffs are
1058
+ * sourced from `structuredPatch` by tools.ts — story 021). Ordering is the linear order story 017
1059
+ * returns (no re-ordering); idempotency across overlapping/prefix re-reads comes from the
1060
+ * per-session `emitted` uuid set (R3.3/R3.4). The JSONL tail is the single source of truth — there
1061
+ * is NO SDK message stream. The end-of-turn predicate is story 024; this only consumes the signal.
1062
+ */
1063
+ async pumpUpdates(sessionId) {
1064
+ const session = this.sessions[sessionId];
1065
+ if (!session)
1066
+ return; // watcher fired before the handle was registered, or after teardown
1067
+ const messages = await readOrderedMessages(sessionId, session.cwd, {
1068
+ getMessages: this.getMessages,
1069
+ });
1070
+ // §10 billing guard-rail (story 022, Group 4.1), GOOD-FAITH: on the first batch, assert the
1071
+ // observed `entrypoint` is the subscription `cli` class and ABORT the session on a credit/`sdk-*`
1072
+ // entrypoint. We only act when the JSONL actually CARRIES an entrypoint — getSessionMessages'
1073
+ // reduced shape frequently omits it, and the PRIMARY protection is the spawn-time env-sanitize
1074
+ // (story 013). The entrypoint is NEVER rewritten/forced (forging it to 'cli' would be evasion).
1075
+ if (!session.guardChecked) {
1076
+ session.guardChecked = true;
1077
+ const firstBilling = messages.find((m) => {
1078
+ const w = m;
1079
+ return ((w.type === "assistant" || w.type === "user") &&
1080
+ typeof w.entrypoint === "string" &&
1081
+ w.entrypoint.length > 0);
1082
+ });
1083
+ if (firstBilling) {
1084
+ let aborted = false;
1085
+ guardEvent(firstBilling, {
1086
+ alert: (m) => this.logger.error(m),
1087
+ stopSession: () => {
1088
+ aborted = true;
1089
+ },
1090
+ });
1091
+ if (aborted) {
1092
+ await this.teardownSession(sessionId);
1093
+ return; // billed entrypoint — abort the session, emit nothing
1094
+ }
1095
+ }
1096
+ }
1097
+ // === SEAM(030) — feed the in-flight turn detector (R5.1). `readOrderedMessages` returns the FULL
1098
+ // monotonic ordered superset on every re-read (engine-watcher.ts), so we slice past a per-session
1099
+ // high-water cursor to feed each raw message to the detector exactly once, in order — a prior
1100
+ // turn's terminal boundary is never re-observed. The detector reads `message.stop_reason` off raw
1101
+ // assistant messages and resolves the pending `session/prompt`. Purely additive: this does NOT
1102
+ // change the emit loop below or the §10 guard-rail above.
1103
+ const fed = messages.slice(session.detectorCursor ?? 0);
1104
+ session.detectorCursor = messages.length;
1105
+ if (session.turnDetector) {
1106
+ for (const m of fed)
1107
+ session.turnDetector.observe(m);
1108
+ }
1109
+ // === SEAM(034) §9 — feed the gate's tool_use.id correlation map from the SAME exactly-once
1110
+ // slice the detector consumes: every newly-observed assistant `tool_use` block registers its id
1111
+ // so a PreToolUse hook call can be matched to a REAL transcript tool_use before it is approved
1112
+ // (request-permission fails closed on a missing/duplicate id). Registration is additive — it
1113
+ // emits nothing and never blocks the pump.
1114
+ if (session.gate) {
1115
+ for (const m of fed)
1116
+ registerGateToolUses(m, session.gate);
1117
+ }
1118
+ // === SEAM(041) §sidechain — source + merge + linearize + emit (BOTH main turns and nested
1119
+ // sub-agent rows). Factored into the shared {@link emitLinearizedWithNested} so the `session/load`
1120
+ // replay path (`replaySessionHistory`) runs the IDENTICAL loop — loaded == live with no replay-only
1121
+ // divergence (R3.2; mirrors why the story-026 diff and story-038 usage moved into `emitTurnUpdates`).
1122
+ // The merge MUST NOT reach the detector / §10 guard / §9 gate above — those already consumed the
1123
+ // un-merged `messages` slice exactly-once (R4.1 structural: sub-agent rows never advance
1124
+ // `detectorCursor` nor register as gate tool_uses).
1125
+ await this.emitLinearizedWithNested(sessionId, session, messages);
1126
+ // === SEAM(044) — Option-B sub-agent watcher: arm/refresh/teardown rides the MAIN-CHAIN spawn
1127
+ // signal (`hasSubagentSpawn` + `spawnIdsOpen` over the FULL pumped messages — design key
1128
+ // decision 4: NOT the detector's `openTaskIds`, the very inference that failed live in the
1129
+ // 041 R4.2 acceptance; scanning the full chain each pump is robust to cursor/slice effects).
1130
+ // While armed, the watcher polls the story-041 SDK sidechain readers; its `onActivity` (fired
1131
+ // only on a NEW-uuid sub-agent row) feeds BOTH liveness — the in-flight detector's
1132
+ // `noteActivity()` (R1.1) — and the incremental render: a re-run of the UNCHANGED idempotent
1133
+ // {@link emitLinearizedWithNested}, whose per-row `emittedNested` dedup guarantees at-most-once
1134
+ // nested emit (R3.1/R3.2). `this.schedule` is the story-030 single timer seam, so ONE fake
1135
+ // clock drives prompt + detector + watcher in tests. Flag OFF → nothing arms: byte-for-byte
1136
+ // today's pull-only path (R4.2).
1137
+ session.lastMessages = messages;
1138
+ if (this.liveSubagentWatch) {
1139
+ if (!session.subagentWatcher && hasSubagentSpawn(messages) && spawnIdsOpen(messages)) {
1140
+ session.subagentWatcher = createSubagentWatcher({
1141
+ sessionId,
1142
+ dir: session.cwd,
1143
+ mainChain: messages,
1144
+ listSubagents: this.listSubagents,
1145
+ getSubagentMessages: this.getSubagentMessages,
1146
+ schedule: this.schedule,
1147
+ onActivity: async () => {
1148
+ session.turnDetector?.noteActivity();
1149
+ await this.emitLinearizedWithNested(sessionId, session, session.lastMessages ?? messages);
1150
+ },
1151
+ });
1152
+ }
1153
+ else if (session.subagentWatcher) {
1154
+ session.subagentWatcher.update(messages);
1155
+ if (!spawnIdsOpen(messages)) {
1156
+ // R2.2: every spawn id on the chain is CLOSED — the sidechain is finished; stop polling.
1157
+ session.subagentWatcher.stop();
1158
+ session.subagentWatcher = undefined;
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1163
+ /**
1164
+ * Story 041 (R3.1, R3.2) — the SHARED source+merge+linearize+emit loop run by BOTH the live tail pump
1165
+ * ({@link pumpUpdates}) and the `session/load` replay ({@link replaySessionHistory}), so a LOADED
1166
+ * thread emits the nested sub-agent rows BYTE-IDENTICALLY to a LIVE one (no replay-only divergence —
1167
+ * the validate-026 lesson: any emit path living only in the pump silently diverges on a replay-only
1168
+ * load). Steps:
1169
+ *
1170
+ * 1. `sourceSubagentRows` — GUARDED (R5.2): returns `[]` WITHOUT touching disk when the main chain
1171
+ * carries no `Task`/`Agent` `tool_use`, so the common no-subagent turn pays nothing and
1172
+ * `forLinearize === messages` (identical-to-pre-change behavior).
1173
+ * 2. Merge the sidechain rows onto the main chain ONLY for linearization — story 017 groups each
1174
+ * sub-agent row onto its spawning turn's uuid-sorted `Turn.nested`.
1175
+ * 3. Per turn: emit the main content behind the UNCHANGED `emitted` gate (a not-yet-emitted
1176
+ * top-level turn renders through `emitTurnUpdates` — toAcpNotifications + structuredPatch diff +
1177
+ * the optional usage_update); then the DECOUPLED nested pass (per-row `emittedNested` dedup, R2.3)
1178
+ * emits each sub-agent row's `tool_call_update`s on the spawning Task's id (R3.1) — this runs EVEN
1179
+ * when the parent uuid is already in `emitted`, so a late-arriving sub-agent row still surfaces.
1180
+ *
1181
+ * The detector / §10 guard / §9 gate feed is NOT part of this helper — it is pump-only and stays on the
1182
+ * un-merged main chain in the caller (sub-agent rows must never advance the detector cursor or register
1183
+ * as gate tool_uses). The replay caller has no detector/gate feed, so it simply does not run one.
1184
+ */
1185
+ async emitLinearizedWithNested(sessionId, session, messages) {
1186
+ const subagentRows = await sourceSubagentRows(sessionId, messages, {
1187
+ dir: session.cwd,
1188
+ listSubagents: this.listSubagents,
1189
+ getSubagentMessages: this.getSubagentMessages,
1190
+ });
1191
+ const forLinearize = subagentRows.length ? messages.concat(subagentRows) : messages;
1192
+ for (const turn of linearizeTurns(forLinearize)) {
1193
+ // Main content — UNCHANGED `emitted` gate: a not-yet-emitted top-level turn renders through the
1194
+ // shared per-turn emission (toAcpNotifications + structuredPatch diff + the optional usage_update),
1195
+ // so live and loaded render byte-for-byte the same (story 026 R3.3; story 038 moved usage_update
1196
+ // into the shared emit).
1197
+ if (!(turn.uuid && session.emitted.has(turn.uuid))) {
1198
+ await this.emitTurnUpdates(sessionId, turn, session.toolUseCache);
1199
+ if (turn.uuid)
1200
+ session.emitted.add(turn.uuid);
1201
+ }
1202
+ // Nested sub-agent rows — DECOUPLED dedup (R2.3). This pass runs EVEN when `turn.uuid` is already
1203
+ // in `session.emitted`, so a sub-agent row that arrived AFTER its spawning turn was emitted (in an
1204
+ // earlier pump) still surfaces. Dedup is per-row via `emittedNested`, independent of the parent
1205
+ // gate. Each nested row's `tool_call_update`s (built by the pure `nestedUpdatesFor`, story 041 task
1206
+ // 2.1) target the spawning Task's id so the sub-agent renders UNDER the Task (R3.1).
1207
+ for (const nested of turn.nested ?? []) {
1208
+ const nuid = nested.uuid;
1209
+ if (nuid && session.emittedNested.has(nuid))
1210
+ continue;
1211
+ for (const note of this.nestedUpdatesFor(sessionId, nested, session.toolUseCache))
1212
+ await this.client.sessionUpdate(note);
1213
+ if (nuid)
1214
+ session.emittedNested.add(nuid);
1215
+ }
1216
+ }
1217
+ }
1218
+ async readTextFile(params) {
1219
+ const response = await this.client.readTextFile(params);
1220
+ return response;
1221
+ }
1222
+ async writeTextFile(params) {
1223
+ const response = await this.client.writeTextFile(params);
1224
+ return response;
1225
+ }
1226
+ async sendAvailableCommandsUpdate(sessionId) {
1227
+ const session = this.sessions[sessionId];
1228
+ if (!session)
1229
+ return;
1230
+ // === SEAM(023) Group 1: read-only Degrau-1 shim — emit a static (empty) command set. The SDK
1231
+ // `query.supportedCommands()` is dropped; slash commands are owned by the interactive TUI in
1232
+ // Degrau-1 and are not enumerable over the read-only JSONL path.
1233
+ // Degrau 2 (030/032): PTY-backed control — surface the TUI's real command set. ===
1234
+ await this.client.sessionUpdate({
1235
+ sessionId,
1236
+ update: {
1237
+ sessionUpdate: "available_commands_update",
1238
+ availableCommands: [],
1239
+ },
1240
+ });
1241
+ }
1242
+ async updateConfigOption(sessionId, configId, value) {
1243
+ const session = this.sessions[sessionId];
1244
+ if (!session)
1245
+ return;
1246
+ await this.applyConfigOptionValue(sessionId, session, configId, value);
1247
+ await this.client.sessionUpdate({
1248
+ sessionId,
1249
+ update: {
1250
+ sessionUpdate: "config_option_update",
1251
+ configOptions: session.configOptions,
1252
+ },
1253
+ });
1254
+ }
1255
+ async applyConfigOptionValue(sessionId, session, configId, value) {
1256
+ if (configId === "mode") {
1257
+ session.modes = { ...session.modes, currentModeId: value };
1258
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
1259
+ }
1260
+ else if (configId === "model") {
1261
+ if (session.models.currentModelId !== value) {
1262
+ // The cached context window was learned for the previous model; reset
1263
+ // to the new model's heuristic so mid-stream updates between now and
1264
+ // the next `result` reflect the user's selection instead of the old
1265
+ // model's window.
1266
+ session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
1267
+ }
1268
+ session.models = { ...session.models, currentModelId: value };
1269
+ // Recompute availableModes for the new model and clamp the current
1270
+ // mode if the SDK no longer offers it (today: "auto" on Haiku).
1271
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal.
1272
+ const newModelInfo = session.modelInfos.find((m) => m.value === value);
1273
+ const newAvailableModes = buildAvailableModes(newModelInfo);
1274
+ // Capture BEFORE mutating session.modes so the log message reflects
1275
+ // the invalidated mode rather than "default".
1276
+ const previousModeId = session.modes.currentModeId;
1277
+ let modeDowngraded = false;
1278
+ if (!newAvailableModes.some((m) => m.id === previousModeId)) {
1279
+ session.modes = {
1280
+ availableModes: newAvailableModes,
1281
+ currentModeId: "default",
1282
+ };
1283
+ // === SEAM(023) Group 1: read-only Degrau-1 shim — local-state downgrade only; the SDK
1284
+ // `query.setPermissionMode("default")` sync is dropped.
1285
+ // Degrau 2 (030/032): PTY-backed control. ===
1286
+ modeDowngraded = true;
1287
+ }
1288
+ else {
1289
+ session.modes = { ...session.modes, availableModes: newAvailableModes };
1290
+ }
1291
+ // Rebuild config options since effort levels depend on the selected model
1292
+ const effortOpt = session.configOptions.find((o) => o.id === "effort");
1293
+ const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
1294
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort);
1295
+ // === SEAM(023) Group 1: the SDK effort sync (query.applyFlagSettings) after a model switch is
1296
+ // dropped — configOptions already reflects the new effort locally.
1297
+ // Degrau 2 (030/032): PTY-backed control. ===
1298
+ // Emit current_mode_update only after session.modes AND
1299
+ // session.configOptions have been fully reconciled. This way, a failure
1300
+ // in the configOptions/effort rebuild above can't leave the client with
1301
+ // a clamped currentModeId but stale configOptions, and the notification
1302
+ // still precedes the caller's config_option_update so order-sensitive
1303
+ // clients update currentModeId before re-rendering the option list.
1304
+ if (modeDowngraded) {
1305
+ await this.client.sessionUpdate({
1306
+ sessionId,
1307
+ update: {
1308
+ sessionUpdate: "current_mode_update",
1309
+ currentModeId: "default",
1310
+ },
1311
+ });
1312
+ }
1313
+ }
1314
+ else {
1315
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
1316
+ // === SEAM(023) Group 1: read-only Degrau-1 shim — local config update only; the SDK
1317
+ // `query.applyFlagSettings` effort sync is dropped.
1318
+ // Degrau 2 (030/032): PTY-backed control. ===
1319
+ }
1320
+ }
1321
+ async getOrCreateSession(params, opts = {}) {
1322
+ const existingSession = this.sessions[params.sessionId];
1323
+ if (existingSession) {
1324
+ const fingerprint = computeSessionFingerprint(params);
1325
+ if (fingerprint === existingSession.sessionFingerprint) {
1326
+ return {
1327
+ sessionId: params.sessionId,
1328
+ modes: existingSession.modes,
1329
+ models: existingSession.models,
1330
+ configOptions: existingSession.configOptions,
1331
+ };
1332
+ }
1333
+ // Session-defining params changed (e.g. cwd pointed at a git worktree,
1334
+ // or MCP servers reconfigured). Tear down the existing session and
1335
+ // recreate it so the underlying Query process picks up the new values.
1336
+ await this.teardownSession(params.sessionId);
1337
+ }
1338
+ const response = await this.createSession({
1339
+ cwd: params.cwd,
1340
+ mcpServers: params.mcpServers ?? [],
1341
+ additionalDirectories: params.additionalDirectories,
1342
+ _meta: params._meta,
1343
+ }, {
1344
+ resume: params.sessionId,
1345
+ replayOnly: opts.replayOnly,
1346
+ });
1347
+ return {
1348
+ sessionId: response.sessionId,
1349
+ modes: response.modes,
1350
+ models: response.models,
1351
+ configOptions: response.configOptions,
1352
+ };
1353
+ }
1354
+ // === SEAM(023) Group 1 (REWRITE): createSession — PTY engine + JSONL tail, NOT the SDK query().
1355
+ //
1356
+ // Spawns the subscription `claude` TUI under a managed PTY (story 013/014), starts the read-only
1357
+ // JSONL tail watcher (story 015), locates the transcript by sessionId glob (file-discovery
1358
+ // watchdog 2000ms) and reads the runtime cwd from INSIDE the JSONL (never decoding the dir name).
1359
+ // Builds models/modes/configOptions from STATIC Degrau-1 defaults (no SDK initializationResult).
1360
+ // Registers a per-session handle (pty + watcher + emitted Set + engine) and returns the ACP
1361
+ // NewSessionResponse shape. NO SDK `query()`/`getAvailableModels`/`q.*`/SDK-embedded binary path here.
1362
+ // The ACP pump that forwards new JSONL messages to the client is Group 2. ====================
1363
+ async createSession(params, creationOpts = {}) {
1364
+ // Allocate the REQUESTED session id (resume/fork branching preserved). For a fresh session this
1365
+ // is a freshly-generated v4 id, but the engine's PTY spawn (story 013) generates the
1366
+ // AUTHORITATIVE id that correlates to the JSONL transcript basename — that engine-spawn id wins
1367
+ // and becomes the session key (see `startedSessionId` below). A resume reattaches to the prior id.
1368
+ let requestedSessionId;
1369
+ const isResume = !!creationOpts.resume && !creationOpts.forkSession;
1370
+ if (creationOpts.forkSession) {
1371
+ requestedSessionId = randomUUID();
1372
+ }
1373
+ else if (creationOpts.resume) {
1374
+ requestedSessionId = creationOpts.resume;
1375
+ }
1376
+ else {
1377
+ requestedSessionId = randomUUID();
1378
+ }
1379
+ // SettingsManager is retained (kept methods read it; teardown disposes it). The PTY TUI reads
1380
+ // the user's settings from disk itself — we no longer translate them into SDK `Options`.
1381
+ const settingsManager = new SettingsManager(params.cwd, {
1382
+ logger: this.logger,
1383
+ });
1384
+ await settingsManager.initialize();
1385
+ // Per-session task state — still surfaced via plan notifications by the Group 2 pump / hooks.
1386
+ const taskState = new Map();
1387
+ // === SEAM(034) §9 hybrid gate: set up the per-session permission gate BEFORE the spawn =======
1388
+ // FRESH spawns only (the resume argv is not extended — the story-029 planMode precedent — and a
1389
+ // replay-only load spawns nothing). Ordering is load-bearing (GATE_FINDINGS blocker c): the
1390
+ // loopback hook server must be LIVE and the scratch settings ON DISK before claude starts,
1391
+ // because claude reads settings only at startup — a late write misses the first tool call.
1392
+ // Setup is fast (one port bind + one tmp-file write) so the story-028 fast-boot contract holds.
1393
+ // On a setup failure createSession FAILS LOUDLY rather than spawning an ungated claude that
1394
+ // looks gated (the blocker-b hazard); `FORK_GATE=off` is the documented escape hatch.
1395
+ const isFreshSpawn = !isResume && !creationOpts.forkSession && !creationOpts.replayOnly;
1396
+ let gate;
1397
+ if (this.gateEnabled && isFreshSpawn) {
1398
+ gate = await setupSessionGate({
1399
+ ...this.gateOptions,
1400
+ client: this.client,
1401
+ onWarn: (m) => this.logger.error(m),
1402
+ });
1403
+ }
1404
+ // Spawn the PTY engine + start the JSONL watcher + locate the transcript via the injectable
1405
+ // seam. For a fresh session `sessionId` is undefined so the engine's spawn generates it; for
1406
+ // resume/fork we hand it the requested id. A failed resume (transcript never found by the
1407
+ // file-discovery watchdog) surfaces as resourceNotFound so the client can recover.
1408
+ let started;
1409
+ try {
1410
+ started = await this.startEngine({
1411
+ sessionId: isResume || creationOpts.forkSession ? requestedSessionId : undefined,
1412
+ cwd: params.cwd,
1413
+ resume: isResume || !!creationOpts.forkSession,
1414
+ replayOnly: creationOpts.replayOnly,
1415
+ sessions: this.engines,
1416
+ onEvent: (sid) => void this.pumpUpdates(sid),
1417
+ // Story 034: the gate's scratch settings file, consumed as `--settings "<file>"` (fresh path).
1418
+ settingsFile: gate?.settingsPath,
1419
+ });
1420
+ }
1421
+ catch (error) {
1422
+ // A failed spawn must not leak the gate's server/scratch (story 034). teardown() is
1423
+ // idempotent and self-catching; the original spawn error stays the surfaced one.
1424
+ // The settingsManager leaks here too (pre-existing: its fs.watch subscriptions held the
1425
+ // process open — exposed by the story-034 gate-wiring spawn-failure test): the session never
1426
+ // reaches the map, so teardownSession can never dispose it. Dispose it on this path.
1427
+ settingsManager.dispose();
1428
+ await gate?.teardown();
1429
+ if (creationOpts.resume && error instanceof Error) {
1430
+ throw RequestError.resourceNotFound(requestedSessionId);
1431
+ }
1432
+ throw error;
1433
+ }
1434
+ const startedSessionId = started.sessionId;
1435
+ if (started.engine) {
1436
+ this.engines.set(startedSessionId, started.engine);
1437
+ }
1438
+ // === SEAM(034): bind the gate to the AUTHORITATIVE session id (engine-spawn-generated) and the
1439
+ // live PTY. The nudge forces a pump re-read on every hook arrival, shrinking the JSONL
1440
+ // tool_use-correlation race; the PTY binding powers the #52822 allow keystroke + prompt probe.
1441
+ // Binding happens BEFORE this method returns the sessionId to Zed, so no `session/prompt` (and
1442
+ // therefore no PreToolUse) can ever observe an unbound gate. PTY exit also tears the gate down
1443
+ // (idempotent with teardownSession) so a crashed TUI leaks no port/server/scratch.
1444
+ if (gate) {
1445
+ const boundGate = gate;
1446
+ boundGate.bindSession(startedSessionId, () => void this.pumpUpdates(startedSessionId));
1447
+ boundGate.bindPty(started.pty);
1448
+ started.pty.onExit(() => void boundGate.teardown());
1449
+ }
1450
+ // Static Degrau-1 model/mode/config defaults (the TUI owns real selection in Degrau-1).
1451
+ const models = buildDegrau1Models();
1452
+ const availableModes = buildAvailableModes(DEGRAU1_DEFAULT_MODEL_INFO);
1453
+ const modes = {
1454
+ currentModeId: "default",
1455
+ availableModes,
1456
+ };
1457
+ const configOptions = buildConfigOptions(modes, models, [DEGRAU1_DEFAULT_MODEL_INFO], settingsManager.getSettings().effortLevel);
1458
+ // Runtime cwd is read from inside the JSONL (story 015); fall back to the requested host cwd
1459
+ // until the first transcript line carries `.cwd` (the seam may return cwd === undefined early).
1460
+ const runtimeCwd = started.cwd ?? params.cwd;
1461
+ this.sessions[startedSessionId] = {
1462
+ pty: started.pty,
1463
+ watcher: started.watcher,
1464
+ emitted: new Set(),
1465
+ emittedNested: new Set(),
1466
+ toolUseCache: {},
1467
+ guardChecked: false,
1468
+ usageDisabled: false,
1469
+ engine: started.engine,
1470
+ cancelled: false,
1471
+ cwd: runtimeCwd,
1472
+ sessionFingerprint: computeSessionFingerprint(params),
1473
+ settingsManager,
1474
+ accumulatedUsage: {
1475
+ inputTokens: 0,
1476
+ outputTokens: 0,
1477
+ cachedReadTokens: 0,
1478
+ cachedWriteTokens: 0,
1479
+ },
1480
+ modes,
1481
+ models,
1482
+ modelInfos: [DEGRAU1_DEFAULT_MODEL_INFO],
1483
+ configOptions,
1484
+ contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
1485
+ taskState,
1486
+ gate,
1487
+ };
1488
+ return {
1489
+ sessionId: startedSessionId,
1490
+ models,
1491
+ modes,
1492
+ configOptions,
1493
+ };
1494
+ }
1495
+ }
1496
+ /**
1497
+ * Build the list of permission modes the agent will advertise for the given
1498
+ * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
1499
+ * the SDK's model-level availability signal. `undefined`/`false` both exclude
1500
+ * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
1501
+ */
1502
+ function buildAvailableModes(modelInfo) {
1503
+ const modes = [];
1504
+ // Only advertise "auto" when the SDK reports the model supports it.
1505
+ if (modelInfo?.supportsAutoMode === true) {
1506
+ modes.push({
1507
+ id: "auto",
1508
+ name: "Auto",
1509
+ description: "Use a model classifier to approve/deny permission prompts",
1510
+ });
1511
+ }
1512
+ modes.push({
1513
+ id: "default",
1514
+ name: "Default",
1515
+ description: "Standard behavior, prompts for dangerous operations",
1516
+ }, {
1517
+ id: "acceptEdits",
1518
+ name: "Accept Edits",
1519
+ description: "Auto-accept file edit operations",
1520
+ }, {
1521
+ id: "plan",
1522
+ name: "Plan Mode",
1523
+ description: "Planning mode, no actual tool execution",
1524
+ }, {
1525
+ id: "dontAsk",
1526
+ name: "Don't Ask",
1527
+ description: "Don't prompt for permissions, deny if not pre-approved",
1528
+ });
1529
+ if (ALLOW_BYPASS) {
1530
+ modes.push({
1531
+ id: "bypassPermissions",
1532
+ name: "Bypass Permissions",
1533
+ description: "Bypass all permission checks",
1534
+ });
1535
+ }
1536
+ return modes;
1537
+ }
1538
+ // Translate a UI effort value into the flag-layer payload. The SDK
1539
+ // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
1540
+ // and only clears a key when an explicit `null` is sent — see
1541
+ // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
1542
+ // `"default"` sentinel and `undefined` (effort option absent for the model) to
1543
+ // `null` ensures any previously-applied flag is actually cleared.
1544
+ function buildConfigOptions(modes, models, modelInfos, currentEffortLevel) {
1545
+ const options = [
1546
+ {
1547
+ id: "mode",
1548
+ name: "Mode",
1549
+ description: "Session permission mode",
1550
+ category: "mode",
1551
+ type: "select",
1552
+ currentValue: modes.currentModeId,
1553
+ options: modes.availableModes.map((m) => ({
1554
+ value: m.id,
1555
+ name: m.name,
1556
+ description: m.description,
1557
+ })),
1558
+ },
1559
+ {
1560
+ id: "model",
1561
+ name: "Model",
1562
+ description: "AI model to use",
1563
+ category: "model",
1564
+ type: "select",
1565
+ currentValue: models.currentModelId,
1566
+ options: models.availableModels.map((m) => ({
1567
+ value: m.modelId,
1568
+ name: m.name,
1569
+ description: m.description ?? undefined,
1570
+ })),
1571
+ },
1572
+ ];
1573
+ // Add effort level option based on the currently selected model
1574
+ const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
1575
+ const supportedLevels = currentModelInfo?.supportsEffort
1576
+ ? (currentModelInfo.supportedEffortLevels ?? [])
1577
+ : [];
1578
+ if (supportedLevels.length > 0) {
1579
+ const effortOptions = [
1580
+ { value: "default", name: "Default" },
1581
+ ...supportedLevels.map((level) => ({
1582
+ value: level,
1583
+ name: level
1584
+ .split(/[_-]/)
1585
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
1586
+ .join(" "),
1587
+ })),
1588
+ ];
1589
+ const includes = (l) => l === "default" || supportedLevels.includes(l);
1590
+ const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
1591
+ options.push({
1592
+ id: "effort",
1593
+ name: "Effort",
1594
+ description: "Available effort levels for this model",
1595
+ category: "thought_level",
1596
+ type: "select",
1597
+ currentValue: validEffort,
1598
+ options: effortOptions,
1599
+ });
1600
+ }
1601
+ return options;
1602
+ }
1603
+ // Claude Code CLI persists display strings like "opus[1m]" in settings,
1604
+ // but the SDK model list uses IDs like "claude-opus-4-6-1m".
1605
+ const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
1606
+ // Captures a model family version such as `4-6` or `4.7` so we can keep
1607
+ // `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
1608
+ // alias currently resolves to a different family version (e.g. Opus 4.7).
1609
+ const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
1610
+ function extractModelFamilyVersion(s) {
1611
+ const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
1612
+ return match ? `${match[1]}.${match[2]}` : null;
1613
+ }
1614
+ function modelVersionsCompatible(preference, candidate) {
1615
+ const preferred = extractModelFamilyVersion(preference);
1616
+ if (!preferred)
1617
+ return true;
1618
+ const candidateVersion = extractModelFamilyVersion(candidate.value) ??
1619
+ extractModelFamilyVersion(candidate.displayName) ??
1620
+ extractModelFamilyVersion(candidate.description);
1621
+ if (!candidateVersion)
1622
+ return true;
1623
+ return preferred === candidateVersion;
1624
+ }
1625
+ function tokenizeModelPreference(model) {
1626
+ const lower = model.trim().toLowerCase();
1627
+ const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
1628
+ const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
1629
+ const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
1630
+ const tokens = rawTokens
1631
+ .map((token) => {
1632
+ if (token === "opusplan")
1633
+ return "opus";
1634
+ if (token === "best" || token === "default")
1635
+ return "";
1636
+ return token;
1637
+ })
1638
+ .filter((token) => token && token !== "claude")
1639
+ .filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
1640
+ return { tokens, contextHint };
1641
+ }
1642
+ function scoreModelMatch(model, tokens, contextHint) {
1643
+ const haystack = `${model.value} ${model.displayName}`.toLowerCase();
1644
+ let score = 0;
1645
+ for (const token of tokens) {
1646
+ if (haystack.includes(token)) {
1647
+ score += token === contextHint ? 3 : 1;
1648
+ }
1649
+ }
1650
+ return score;
1651
+ }
1652
+ function resolveModelPreference(models, preference) {
1653
+ const trimmed = preference.trim();
1654
+ if (!trimmed)
1655
+ return null;
1656
+ const lower = trimmed.toLowerCase();
1657
+ // Exact match on value or display name
1658
+ const directMatch = models.find((model) => model.value === trimmed ||
1659
+ model.value.toLowerCase() === lower ||
1660
+ model.displayName.toLowerCase() === lower);
1661
+ if (directMatch)
1662
+ return directMatch;
1663
+ // Substring match
1664
+ const includesMatch = models.find((model) => {
1665
+ if (!modelVersionsCompatible(trimmed, model))
1666
+ return false;
1667
+ const value = model.value.toLowerCase();
1668
+ const display = model.displayName.toLowerCase();
1669
+ return value.includes(lower) || display.includes(lower) || lower.includes(value);
1670
+ });
1671
+ if (includesMatch)
1672
+ return includesMatch;
1673
+ // Tokenized matching for aliases like "opus[1m]"
1674
+ const { tokens, contextHint } = tokenizeModelPreference(trimmed);
1675
+ if (tokens.length === 0)
1676
+ return null;
1677
+ let bestMatch = null;
1678
+ let bestScore = 0;
1679
+ for (const model of models) {
1680
+ if (!modelVersionsCompatible(trimmed, model))
1681
+ continue;
1682
+ const score = scoreModelMatch(model, tokens, contextHint);
1683
+ if (0 < score && (!bestMatch || bestScore < score)) {
1684
+ bestMatch = model;
1685
+ bestScore = score;
1686
+ }
1687
+ }
1688
+ return bestMatch;
1689
+ }
1690
+ /**
1691
+ * Inline-vs-reference threshold for an embedded `resource` block, in characters
1692
+ * of `resource.text`.
1693
+ *
1694
+ * Content at or above this size is referenced by `@<path>` so the TUI re-reads
1695
+ * the file from disk instead of receiving its bytes over the PTY (avoids
1696
+ * flooding the terminal with a large paste). Content below it is inlined so a
1697
+ * tiny snippet of context is not lost when there is no point round-tripping
1698
+ * through the filesystem.
1699
+ *
1700
+ * This is the SINGLE source of truth: both the `@<path>` and the inline outcomes
1701
+ * key off this one constant — no duplicated magic numbers.
1702
+ */
1703
+ export const EMBEDDED_RESOURCE_INLINE_THRESHOLD = 2048;
1704
+ /**
1705
+ * Derive a filesystem path from a `file://` URI, or `null` when the URI is not a
1706
+ * resolvable file path. Shared by the `resource_link` and embedded `resource`
1707
+ * branches of {@link promptToClaude} so the `file://` → path derivation lives in
1708
+ * exactly one place. Never throws.
1709
+ */
1710
+ function filePathFromUri(uri) {
1711
+ return uri.startsWith("file://") ? uri.slice("file://".length) : null;
1712
+ }
1713
+ /**
1714
+ * Convert an ACP ContentBlock[] (session/prompt) into a PTY text payload string.
1715
+ *
1716
+ * Reverse-flow (IMPLEMENTACAO-FORK-ACP.md §8): instead of constructing an
1717
+ * SDKUserMessage for the core SDK, we assemble the text the TUI should receive
1718
+ * over the PTY. Each block yields a fragment; empty fragments are dropped and the
1719
+ * survivors are joined with a single space.
1720
+ *
1721
+ * - text → injected verbatim (with the legacy /mcp: slash-command
1722
+ * normalization preserved, since the TUI understands it).
1723
+ * - resource_link → `@<path>` for file:// URIs (the TUI re-reads the file), or a
1724
+ * `[name](uri)` markdown link for any non-path URI.
1725
+ * - resource (text) → large content (≥ EMBEDDED_RESOURCE_INLINE_THRESHOLD) with
1726
+ * a file:// path becomes `@<path>` so the TUI re-reads it;
1727
+ * anything below the threshold — or large but path-less — is
1728
+ * inlined directly so the context is not lost.
1729
+ *
1730
+ * `resource` (blob) / `image` / `audio` blocks are SILENT no-ops here (R4.1): they
1731
+ * emit no PTY bytes and are NOT logged — they are expected-but-unsupported media in
1732
+ * v1, not errors. An UNKNOWN block `type` (the `default` branch) and any block whose
1733
+ * mapping THROWS are treated as malformed: skipped, recorded via the `logger`, and the
1734
+ * remaining valid blocks still map — one bad block never aborts the whole prompt (R1.3).
1735
+ */
1736
+ export function promptToClaude(prompt, logger = console) {
1737
+ const fragments = [];
1738
+ for (const chunk of prompt.prompt) {
1739
+ // R1.3: isolate every block. A malformed block — even one whose `type` getter
1740
+ // throws — is SKIPPED and RECORDED, never allowed to abort the remaining blocks.
1741
+ // Reading `chunk.type` happens INSIDE the try so a throwing accessor is caught too.
1742
+ try {
1743
+ switch (chunk.type) {
1744
+ case "text": {
1745
+ let text = chunk.text;
1746
+ // change /mcp:server:command args -> /server:command (MCP) args
1747
+ const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
1748
+ if (mcpMatch) {
1749
+ const [, server, command, args] = mcpMatch;
1750
+ text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
1751
+ }
1752
+ fragments.push(text);
1753
+ break;
1754
+ }
1755
+ case "resource_link": {
1756
+ const path = filePathFromUri(chunk.uri);
1757
+ if (path !== null) {
1758
+ // @<path> so the TUI re-reads the file (do not inline its bytes).
1759
+ fragments.push(`@${path}`);
1760
+ }
1761
+ else {
1762
+ // Non-path uri (http(s)://, zed://, …): markdown link, never a bare @.
1763
+ const label = chunk.name ?? chunk.uri;
1764
+ fragments.push(`[${label}](${chunk.uri})`);
1765
+ }
1766
+ break;
1767
+ }
1768
+ case "resource": {
1769
+ // Only text resources are handled here; a blob resource (no `text`) is a
1770
+ // SILENT no-op — it is ignored media (R4.1), not malformed, so NO log.
1771
+ // Never throw on a missing field.
1772
+ if (chunk.resource && "text" in chunk.resource) {
1773
+ const content = chunk.resource.text;
1774
+ const path = filePathFromUri(chunk.resource.uri);
1775
+ if (content.length >= EMBEDDED_RESOURCE_INLINE_THRESHOLD && path !== null) {
1776
+ // Large + resolvable path → reference by @<path>, TUI re-reads (R3.1).
1777
+ fragments.push(`@${path}`);
1778
+ }
1779
+ else {
1780
+ // Below threshold (R3.2), or large but path-less (R3.3) → inline the
1781
+ // raw content rather than emit a broken @ mention.
1782
+ fragments.push(content);
1783
+ }
1784
+ }
1785
+ break;
1786
+ }
1787
+ // image / audio → SILENT no-ops (R4.1): expected-but-unsupported media in v1.
1788
+ // They emit no PTY bytes and are NOT logged (they are not errors).
1789
+ case "image":
1790
+ case "audio":
1791
+ break;
1792
+ default:
1793
+ // An unrecognized block type is malformed: skip it AND record the skip,
1794
+ // consistent with the throwing-block path (R1.3). Still no throw.
1795
+ logger.error("promptToClaude: skipped an unknown content block", chunk.type);
1796
+ break;
1797
+ }
1798
+ }
1799
+ catch (err) {
1800
+ // R1.3: a block whose mapping threw is isolated — record the skip and continue
1801
+ // to the next block. The function still returns the payload from the valid blocks.
1802
+ logger.error("promptToClaude: skipped a malformed content block", err);
1803
+ continue;
1804
+ }
1805
+ }
1806
+ return fragments.filter((fragment) => fragment.length > 0).join(" ");
1807
+ }
1808
+ /**
1809
+ * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
1810
+ * Only handles text, image, and thinking chunks for now.
1811
+ */
1812
+ export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
1813
+ const taskState = options?.taskState ?? new Map();
1814
+ const registerHooks = options?.registerHooks !== false;
1815
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
1816
+ if (typeof content === "string") {
1817
+ const update = {
1818
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1819
+ content: {
1820
+ type: "text",
1821
+ text: content,
1822
+ },
1823
+ };
1824
+ if (options?.parentToolUseId) {
1825
+ update._meta = {
1826
+ ...update._meta,
1827
+ claudeCode: {
1828
+ ...(update._meta?.claudeCode || {}),
1829
+ parentToolUseId: options.parentToolUseId,
1830
+ },
1831
+ };
1832
+ }
1833
+ return [{ sessionId, update }];
1834
+ }
1835
+ const output = [];
1836
+ // Only handle the first chunk for streaming; extend as needed for batching
1837
+ for (const chunk of content) {
1838
+ let update = null;
1839
+ switch (chunk.type) {
1840
+ case "text":
1841
+ case "text_delta":
1842
+ update = {
1843
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1844
+ content: {
1845
+ type: "text",
1846
+ text: chunk.text,
1847
+ },
1848
+ };
1849
+ break;
1850
+ case "image":
1851
+ update = {
1852
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1853
+ content: {
1854
+ type: "image",
1855
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
1856
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
1857
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
1858
+ },
1859
+ };
1860
+ break;
1861
+ case "thinking":
1862
+ case "thinking_delta":
1863
+ update = {
1864
+ sessionUpdate: "agent_thought_chunk",
1865
+ content: {
1866
+ type: "text",
1867
+ text: chunk.thinking,
1868
+ },
1869
+ };
1870
+ break;
1871
+ case "tool_use":
1872
+ case "server_tool_use":
1873
+ case "mcp_tool_use": {
1874
+ const alreadyCached = chunk.id in toolUseCache;
1875
+ toolUseCache[chunk.id] = chunk;
1876
+ if (chunk.name === "TodoWrite") {
1877
+ // @ts-expect-error - sometimes input is empty object or undefined
1878
+ if (Array.isArray(chunk.input?.todos)) {
1879
+ update = {
1880
+ sessionUpdate: "plan",
1881
+ entries: planEntries(chunk.input),
1882
+ };
1883
+ }
1884
+ }
1885
+ else if (chunk.name === "TaskCreate" ||
1886
+ chunk.name === "TaskUpdate" ||
1887
+ chunk.name === "TaskList" ||
1888
+ chunk.name === "TaskGet") {
1889
+ // Task* tool_use is suppressed; the plan update is emitted at
1890
+ // tool_result time once we have the task ID (for TaskCreate) and
1891
+ // confirmation that the change took effect.
1892
+ }
1893
+ else {
1894
+ // Only register hooks on first encounter to avoid double-firing
1895
+ if (registerHooks && !alreadyCached) {
1896
+ registerHookCallback(chunk.id, {
1897
+ onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
1898
+ const toolUse = toolUseCache[toolUseId];
1899
+ if (toolUse) {
1900
+ // Both `Edit` and `Write` produce a structuredPatch in their
1901
+ // PostToolUse tool_response. For Edit the diff replaces the
1902
+ // optimistic content built at tool_use time. For Write the
1903
+ // optimistic content (built from `input.content` alone with
1904
+ // `oldText: null`) shows "creation" semantics regardless of
1905
+ // whether the file existed; the structuredPatch from the
1906
+ // hook lets us emit the real diff for `type: "update"`. The
1907
+ // helper returns `{}` if the response shape isn't usable.
1908
+ const editDiff = toolUse.name === "Edit" || toolUse.name === "Write"
1909
+ ? toolUpdateFromDiffToolResponse(toolResponse)
1910
+ : {};
1911
+ const update = {
1912
+ _meta: {
1913
+ claudeCode: {
1914
+ toolResponse,
1915
+ toolName: toolUse.name,
1916
+ },
1917
+ },
1918
+ toolCallId: toolUseId,
1919
+ sessionUpdate: "tool_call_update",
1920
+ ...editDiff,
1921
+ };
1922
+ await client.sessionUpdate({
1923
+ sessionId,
1924
+ update,
1925
+ });
1926
+ }
1927
+ else {
1928
+ logger.error(`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
1929
+ }
1930
+ },
1931
+ });
1932
+ }
1933
+ let rawInput;
1934
+ try {
1935
+ rawInput = JSON.parse(JSON.stringify(chunk.input));
1936
+ }
1937
+ catch {
1938
+ // ignore if we can't turn it to JSON
1939
+ }
1940
+ if (alreadyCached) {
1941
+ // Second encounter (full assistant message after streaming) —
1942
+ // send as tool_call_update to refine the existing tool_call
1943
+ // rather than emitting a duplicate tool_call.
1944
+ update = {
1945
+ _meta: {
1946
+ claudeCode: {
1947
+ toolName: chunk.name,
1948
+ },
1949
+ },
1950
+ toolCallId: chunk.id,
1951
+ sessionUpdate: "tool_call_update",
1952
+ rawInput,
1953
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1954
+ };
1955
+ }
1956
+ else {
1957
+ // First encounter (streaming content_block_start or replay) —
1958
+ // send as tool_call with terminal_info for Bash tools.
1959
+ update = {
1960
+ _meta: {
1961
+ claudeCode: {
1962
+ toolName: chunk.name,
1963
+ },
1964
+ ...(chunk.name === "Bash" && supportsTerminalOutput
1965
+ ? { terminal_info: { terminal_id: chunk.id } }
1966
+ : {}),
1967
+ },
1968
+ toolCallId: chunk.id,
1969
+ sessionUpdate: "tool_call",
1970
+ rawInput,
1971
+ status: "pending",
1972
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1973
+ };
1974
+ }
1975
+ }
1976
+ break;
1977
+ }
1978
+ case "tool_result":
1979
+ case "tool_search_tool_result":
1980
+ case "web_fetch_tool_result":
1981
+ case "web_search_tool_result":
1982
+ case "code_execution_tool_result":
1983
+ case "bash_code_execution_tool_result":
1984
+ case "text_editor_code_execution_tool_result":
1985
+ case "mcp_tool_result": {
1986
+ const toolUse = toolUseCache[chunk.tool_use_id];
1987
+ if (!toolUse) {
1988
+ logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1989
+ break;
1990
+ }
1991
+ if (toolUse.name === "TaskCreate" ||
1992
+ toolUse.name === "TaskUpdate" ||
1993
+ toolUse.name === "TaskList" ||
1994
+ toolUse.name === "TaskGet") {
1995
+ // Headless/SDK sessions emit Task* tools instead of TodoWrite.
1996
+ // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
1997
+ // and TaskGet are read-only so we just suppress their tool_call /
1998
+ // tool_result events. The plan update is emitted as a snapshot of
1999
+ // the accumulated state, mirroring the legacy TodoWrite behavior.
2000
+ const isError = "is_error" in chunk && chunk.is_error;
2001
+ if (!isError) {
2002
+ if (toolUse.name === "TaskCreate") {
2003
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
2004
+ }
2005
+ else if (toolUse.name === "TaskUpdate") {
2006
+ applyTaskUpdate(taskState, toolUse.input);
2007
+ }
2008
+ }
2009
+ if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
2010
+ update = {
2011
+ sessionUpdate: "plan",
2012
+ entries: taskStateToPlanEntries(taskState),
2013
+ };
2014
+ }
2015
+ }
2016
+ else if (toolUse.name !== "TodoWrite") {
2017
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
2018
+ // When terminal output is supported, send terminal_output as a
2019
+ // separate notification to match codex-acp's streaming lifecycle:
2020
+ // 1. tool_call → _meta.terminal_info (already sent above)
2021
+ // 2. tool_call_update → _meta.terminal_output (sent here)
2022
+ // 3. tool_call_update → _meta.terminal_exit (sent below with status)
2023
+ if (toolMeta?.terminal_output) {
2024
+ output.push({
2025
+ sessionId,
2026
+ update: {
2027
+ _meta: {
2028
+ terminal_output: toolMeta.terminal_output,
2029
+ ...(options?.parentToolUseId
2030
+ ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
2031
+ : {}),
2032
+ },
2033
+ toolCallId: chunk.tool_use_id,
2034
+ sessionUpdate: "tool_call_update",
2035
+ },
2036
+ });
2037
+ }
2038
+ update = {
2039
+ _meta: {
2040
+ claudeCode: {
2041
+ toolName: toolUse.name,
2042
+ },
2043
+ ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
2044
+ },
2045
+ toolCallId: chunk.tool_use_id,
2046
+ sessionUpdate: "tool_call_update",
2047
+ status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
2048
+ rawOutput: chunk.content,
2049
+ ...toolUpdate,
2050
+ };
2051
+ }
2052
+ break;
2053
+ }
2054
+ case "document":
2055
+ case "search_result":
2056
+ case "redacted_thinking":
2057
+ case "input_json_delta":
2058
+ case "citations_delta":
2059
+ case "signature_delta":
2060
+ case "container_upload":
2061
+ case "compaction":
2062
+ case "compaction_delta":
2063
+ case "advisor_tool_result":
2064
+ case "mid_conv_system":
2065
+ break;
2066
+ default:
2067
+ unreachable(chunk, logger);
2068
+ break;
2069
+ }
2070
+ if (update) {
2071
+ if (options?.parentToolUseId) {
2072
+ update._meta = {
2073
+ ...update._meta,
2074
+ claudeCode: {
2075
+ ...(update._meta?.claudeCode || {}),
2076
+ parentToolUseId: options.parentToolUseId,
2077
+ },
2078
+ };
2079
+ }
2080
+ output.push({ sessionId, update });
2081
+ }
2082
+ }
2083
+ return output;
2084
+ }
2085
+ export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
2086
+ const event = message.event;
2087
+ switch (event.type) {
2088
+ case "content_block_start":
2089
+ return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
2090
+ clientCapabilities: options?.clientCapabilities,
2091
+ parentToolUseId: message.parent_tool_use_id,
2092
+ cwd: options?.cwd,
2093
+ taskState: options?.taskState,
2094
+ });
2095
+ case "content_block_delta":
2096
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
2097
+ clientCapabilities: options?.clientCapabilities,
2098
+ parentToolUseId: message.parent_tool_use_id,
2099
+ cwd: options?.cwd,
2100
+ taskState: options?.taskState,
2101
+ });
2102
+ // No content. `ping` is a Messages-API keep-alive event that the SDK's
2103
+ // `BetaRawMessageStreamEvent` union doesn't include even though the
2104
+ // wire format emits it; the `as never` cast lets us no-op it here
2105
+ // instead of letting it fall through to `unreachable`.
2106
+ case "ping":
2107
+ case "message_start":
2108
+ case "message_delta":
2109
+ case "message_stop":
2110
+ case "content_block_stop":
2111
+ return [];
2112
+ default:
2113
+ unreachable(event, logger);
2114
+ return [];
2115
+ }
2116
+ }
2117
+ export function runAcp(deps) {
2118
+ const input = nodeToWebWritable(process.stdout);
2119
+ const output = nodeToWebReadable(process.stdin);
2120
+ const stream = ndJsonStream(input, output);
2121
+ let agent;
2122
+ const connection = new AgentSideConnection((client) => {
2123
+ // Positions 2-3 (logger/engine) default; `deps` carries the bootstrap-resolved flags
2124
+ // (story 038: usageUpdate from process.env.USAGE_UPDATE, default OFF).
2125
+ agent = new ClaudeAcpAgent(client, undefined, undefined, deps);
2126
+ return agent;
2127
+ }, stream);
2128
+ return { connection, agent };
2129
+ }
2130
+ /** Best-effort first guess of a model's context window from its ID, used only
2131
+ * until a `result` message arrives with the authoritative `modelUsage` value.
2132
+ * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
2133
+ * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
2134
+ * matching things like "10m" or embedded substrings. */
2135
+ function inferContextWindowFromModel(model) {
2136
+ if (/\b1m\b/i.test(model))
2137
+ return 1_000_000;
2138
+ return null;
2139
+ }