@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.
- package/LICENSE +191 -0
- package/NOTICE +14 -0
- package/README.md +50 -0
- package/dist/acp-agent.d.ts +594 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +2139 -0
- package/dist/ansi-mirror.d.ts +42 -0
- package/dist/ansi-mirror.d.ts.map +1 -0
- package/dist/ansi-mirror.js +61 -0
- package/dist/besteffort.d.ts +44 -0
- package/dist/besteffort.d.ts.map +1 -0
- package/dist/besteffort.js +100 -0
- package/dist/billing/entrypoint-guard.d.ts +97 -0
- package/dist/billing/entrypoint-guard.d.ts.map +1 -0
- package/dist/billing/entrypoint-guard.js +166 -0
- package/dist/claude-path.d.ts +12 -0
- package/dist/claude-path.d.ts.map +1 -0
- package/dist/claude-path.js +61 -0
- package/dist/diff-enriched-reader.d.ts +41 -0
- package/dist/diff-enriched-reader.d.ts.map +1 -0
- package/dist/diff-enriched-reader.js +106 -0
- package/dist/diff-source.d.ts +104 -0
- package/dist/diff-source.d.ts.map +1 -0
- package/dist/diff-source.js +164 -0
- package/dist/end-of-turn.d.ts +172 -0
- package/dist/end-of-turn.d.ts.map +1 -0
- package/dist/end-of-turn.js +415 -0
- package/dist/engine-lifecycle.d.ts +222 -0
- package/dist/engine-lifecycle.d.ts.map +1 -0
- package/dist/engine-lifecycle.js +236 -0
- package/dist/engine-pty.d.ts +143 -0
- package/dist/engine-pty.d.ts.map +1 -0
- package/dist/engine-pty.js +222 -0
- package/dist/engine-watcher.d.ts +83 -0
- package/dist/engine-watcher.d.ts.map +1 -0
- package/dist/engine-watcher.js +173 -0
- package/dist/engine.d.ts +30 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +34 -0
- package/dist/event-switch.d.ts +164 -0
- package/dist/event-switch.d.ts.map +1 -0
- package/dist/event-switch.js +206 -0
- package/dist/gate/port.d.ts +38 -0
- package/dist/gate/port.d.ts.map +1 -0
- package/dist/gate/port.js +126 -0
- package/dist/gate/settings-writer.d.ts +130 -0
- package/dist/gate/settings-writer.d.ts.map +1 -0
- package/dist/gate/settings-writer.js +349 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +106 -0
- package/dist/jsonl.d.ts +267 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +527 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +5 -0
- package/dist/linearize.d.ts +219 -0
- package/dist/linearize.d.ts.map +1 -0
- package/dist/linearize.js +444 -0
- package/dist/live-diff-env.d.ts +7 -0
- package/dist/live-diff-env.d.ts.map +1 -0
- package/dist/live-diff-env.js +18 -0
- package/dist/live-subagent-env.d.ts +7 -0
- package/dist/live-subagent-env.d.ts.map +1 -0
- package/dist/live-subagent-env.js +19 -0
- package/dist/permissions/allow-inject.d.ts +67 -0
- package/dist/permissions/allow-inject.d.ts.map +1 -0
- package/dist/permissions/allow-inject.js +85 -0
- package/dist/permissions/deny.d.ts +60 -0
- package/dist/permissions/deny.d.ts.map +1 -0
- package/dist/permissions/deny.js +81 -0
- package/dist/permissions/gate-wiring.d.ts +112 -0
- package/dist/permissions/gate-wiring.d.ts.map +1 -0
- package/dist/permissions/gate-wiring.js +350 -0
- package/dist/permissions/hook-server.d.ts +72 -0
- package/dist/permissions/hook-server.d.ts.map +1 -0
- package/dist/permissions/hook-server.js +179 -0
- package/dist/permissions/permission-mode.d.ts +67 -0
- package/dist/permissions/permission-mode.d.ts.map +1 -0
- package/dist/permissions/permission-mode.js +100 -0
- package/dist/permissions/request-permission.d.ts +102 -0
- package/dist/permissions/request-permission.d.ts.map +1 -0
- package/dist/permissions/request-permission.js +124 -0
- package/dist/settings.d.ts +68 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +182 -0
- package/dist/stop-reason-map.d.ts +17 -0
- package/dist/stop-reason-map.d.ts.map +1 -0
- package/dist/stop-reason-map.js +33 -0
- package/dist/subagent-source.d.ts +63 -0
- package/dist/subagent-source.d.ts.map +1 -0
- package/dist/subagent-source.js +132 -0
- package/dist/subagent-watcher.d.ts +40 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +108 -0
- package/dist/tools.d.ts +119 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +729 -0
- package/dist/usage-env.d.ts +7 -0
- package/dist/usage-env.d.ts.map +1 -0
- package/dist/usage-env.js +16 -0
- package/dist/usage.d.ts +54 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +53 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +83 -0
- package/dist/zed-register.d.ts +26 -0
- package/dist/zed-register.d.ts.map +1 -0
- package/dist/zed-register.js +106 -0
- 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
|
+
}
|