@bivy/bivy 0.0.0 → 0.1.0-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,1364 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Claude Code (Claude Agent SDK) adapter — a second concrete AgentRuntime.
4
+ //
5
+ // Maps Anthropic's `@anthropic-ai/claude-agent-sdk` onto the runtime-agnostic
6
+ // interface in ./types.ts so the daemon never sees an SDK type. Like pi.ts, this
7
+ // is the only place that touches the Claude Agent SDK.
8
+ //
9
+ // Design notes:
10
+ // * One long-lived `query()` per session, driven by a streaming-input async
11
+ // iterable (an AsyncQueue) so a session can take many prompts (multi-turn)
12
+ // instead of spawning a fresh agent per prompt the way the generic CLI does.
13
+ // * The guardian/approval hook is wired through the SDK's `canUseTool`
14
+ // permission callback, which maps cleanly onto our generic `toolInterceptor`.
15
+ // * The SDK is loaded with a dynamic import so it stays an *optional*
16
+ // dependency: a Bivy install only needs it when this runtime is selected.
17
+ import { createRequire } from "node:module";
18
+ import { randomUUID } from "node:crypto";
19
+ import { EventEmitter } from "node:events";
20
+ import { spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import { depCacheEnv } from "../harness/dep-cache.js";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { sandboxTier, claudePermissionModeFor } from "../harness/sandbox.js";
26
+ import { anthropicCredentialPreflight, describeAnthropicError, isAnthropicAuthError } from "./anthropic-preflight.js";
27
+ import { toModelInfo as sharedToModelInfo } from "./normalize.js";
28
+ import { hasLiveProcessForCwd } from "./native-process-scan.js";
29
+ /** Binary names a live Claude Code process could be running under (see
30
+ * native-process-scan.ts's best-effort cwd match). */
31
+ const CLAUDE_BIN_NAMES = ["claude"];
32
+ /** Whether the standalone `claude` CLI (the TUI) is on PATH on this node. */
33
+ export function claudeCliAvailable() {
34
+ const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? ["claude"] : ["-v", "claude"], {
35
+ shell: process.platform !== "win32",
36
+ stdio: "ignore",
37
+ });
38
+ return result.status === 0;
39
+ }
40
+ // Kept as a variable (not a string literal) so `tsc` treats the dynamic import
41
+ // as `any` and does not require the optional SDK to be installed to typecheck.
42
+ const SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
43
+ // AskUserQuestion is NOT handled here: it's a Bivy-owned feature that lives in
44
+ // the guardian tool-interceptor (see src/question.ts), so it works for every
45
+ // runtime with capabilities.toolInterception rather than being reimplemented per
46
+ // SDK. The interceptor is invoked from canUseTool below like any other tool.
47
+ // Fallback model list for the picker before the agent subprocess is up.
48
+ // getModels() is called to render the model picker (e.g. right after an
49
+ // OAuth sign-in, before the user sends anything), but the SDK's
50
+ // supportedModels() only runs once ensureStarted() has spawned the query on
51
+ // the first prompt. Until then this.models is empty and the picker would show
52
+ // nothing. These are the models a Claude Pro/Max subscription exposes; once
53
+ // the query is up, supportedModels() replaces them with the authoritative set.
54
+ const FALLBACK_MODELS = [
55
+ { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true },
56
+ { provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true },
57
+ { provider: "anthropic", id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", reasoning: true },
58
+ ];
59
+ export function claudeRuntimeFromEnv() {
60
+ return {
61
+ defaultModel: process.env.BIVY_CLAUDE_MODEL?.trim() || undefined,
62
+ sessionsDir: process.env.BIVY_CLAUDE_SESSIONS_DIR?.trim() || undefined,
63
+ };
64
+ }
65
+ /** True when `@anthropic-ai/claude-agent-sdk` is resolvable in this install. */
66
+ export function claudeSdkInstalled() {
67
+ try {
68
+ createRequire(import.meta.url).resolve(SDK_PACKAGE);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ async function loadSdk() {
76
+ try {
77
+ return await import(SDK_PACKAGE);
78
+ }
79
+ catch {
80
+ throw new Error(`The claude-code-sdk runtime requires the "${SDK_PACKAGE}" package. Install it with: npm install ${SDK_PACKAGE}`);
81
+ }
82
+ }
83
+ /**
84
+ * Single-producer async queue used as the SDK's streaming prompt input. Pushing a
85
+ * message either satisfies a pending `next()` or buffers it; closing ends the
86
+ * iterator so the underlying `query()` shuts down cleanly.
87
+ */
88
+ class AsyncQueue {
89
+ values = [];
90
+ resolvers = [];
91
+ done = false;
92
+ push(value) {
93
+ if (this.done)
94
+ return;
95
+ const resolve = this.resolvers.shift();
96
+ if (resolve)
97
+ resolve({ value, done: false });
98
+ else
99
+ this.values.push(value);
100
+ }
101
+ close() {
102
+ if (this.done)
103
+ return;
104
+ this.done = true;
105
+ let resolve;
106
+ while ((resolve = this.resolvers.shift()))
107
+ resolve({ value: undefined, done: true });
108
+ }
109
+ [Symbol.asyncIterator]() {
110
+ return {
111
+ next: () => {
112
+ const value = this.values.shift();
113
+ if (value !== undefined)
114
+ return Promise.resolve({ value, done: false });
115
+ if (this.done)
116
+ return Promise.resolve({ value: undefined, done: true });
117
+ return new Promise((resolve) => this.resolvers.push(resolve));
118
+ },
119
+ };
120
+ }
121
+ }
122
+ /**
123
+ * Build the composer's slash-command list from the SDK's system/init message.
124
+ * `slash_commands` are the built-in + custom (.claude/commands) + plugin
125
+ * commands; `skills` are exposed as "/name" too (the SDK runs a skill from a
126
+ * matching slash). Names arrive without a leading slash, so we normalize to
127
+ * "/name", drop blanks, and dedupe (slash_commands win over a same-named skill).
128
+ * Exported for unit testing. Descriptions aren't in init, so names stand alone.
129
+ */
130
+ export function claudeCommandsFromInit(message) {
131
+ const seen = new Set();
132
+ const out = [];
133
+ const add = (raw) => {
134
+ const base = typeof raw === "string" ? raw.trim().replace(/^\/+/, "") : "";
135
+ if (!base)
136
+ return;
137
+ const name = `/${base}`;
138
+ if (seen.has(name))
139
+ return;
140
+ seen.add(name);
141
+ out.push({ name });
142
+ };
143
+ const slash = message?.slash_commands;
144
+ if (Array.isArray(slash))
145
+ for (const c of slash)
146
+ add(c);
147
+ const skills = message?.skills;
148
+ if (Array.isArray(skills))
149
+ for (const s of skills)
150
+ add(s);
151
+ return out;
152
+ }
153
+ /** Order-insensitive equality on command names — so we only re-advertise (and
154
+ * re-render the composer menu) when the set actually changed. */
155
+ function sameCommands(a, b) {
156
+ if (a.length !== b.length)
157
+ return false;
158
+ const names = new Set(b.map((c) => c.name));
159
+ return a.every((c) => names.has(c.name));
160
+ }
161
+ function extractText(message) {
162
+ const content = message?.content;
163
+ if (typeof content === "string")
164
+ return content;
165
+ if (!Array.isArray(content))
166
+ return "";
167
+ return content
168
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
169
+ .map((block) => block.text)
170
+ .join("");
171
+ }
172
+ /** Model-only "meta" turns the CLI injects for its *own* benefit — task-completion
173
+ * notifications and injected `<system-reminder>` blocks. These are pure noise in
174
+ * a human transcript and must never render as chat. Kept a deliberately narrow
175
+ * known-tag allowlist (not "any leading <tag>") so a real user prompt that
176
+ * happens to start with e.g. "<div>" is never dropped. Interrupt markers are
177
+ * handled separately (see INTERRUPT_MARKER) because — unlike these — they carry
178
+ * meaning a human wants to see. */
179
+ const DROP_META_TEXT = /^\s*<(?:task-notification|system-reminder)[\s>/]/;
180
+ /** The CLI writes this synthetic user-role marker whenever a turn is aborted —
181
+ * by a real user Stop OR by any teardown of the streaming query (our shutdown,
182
+ * a mid-flight credential reload, a TUI refresh). We don't drop it: we surface
183
+ * it as a system notice, labeled by cause (see interruptNoticeText). */
184
+ const INTERRUPT_MARKER = /^\s*\[Request interrupted by user/;
185
+ function hasMetaFlag(entry) {
186
+ return entry?.isMeta === true || entry?.isCompactSummary === true || entry?.isSynthetic === true;
187
+ }
188
+ /** True when a role:"user" turn is a model-only meta injection (see
189
+ * DROP_META_TEXT) rather than a real human prompt. Callers must exclude
190
+ * tool_result-bearing turns first — those carry real tool output, never meta. */
191
+ function isDropMetaText(content) {
192
+ return DROP_META_TEXT.test(extractText({ content }));
193
+ }
194
+ /** True when a turn's text is the CLI's "[Request interrupted by user]" marker. */
195
+ function isInterruptText(content) {
196
+ return INTERRUPT_MARKER.test(extractText({ content }));
197
+ }
198
+ /** Human-facing label for an interrupt marker, classified by cause so we never
199
+ * blame a redeploy or a credential refresh on the user. The CLI tags any
200
+ * interrupt caused by tearing the process/query down (our shutdown, a
201
+ * credential-reload `query.close()`, a TUI refresh) with a top-level
202
+ * `interruptedByShutdown: true` on the transcript entry; a cooperative user
203
+ * Stop (`query.interrupt()`) is written without it. `forceUserStop` lets the
204
+ * live path assert certainty when *we* just called abort() for a real Stop.
205
+ * NOTE: the `interruptedByShutdown` semantics are inferred from on-disk
206
+ * evidence, not the (unvendored) CLI source — worth a one-time empirical check. */
207
+ function interruptNoticeText(entry, forceUserStop = false) {
208
+ if (!forceUserStop && entry?.interruptedByShutdown === true) {
209
+ return "Interrupted — the session was restarted.";
210
+ }
211
+ return "Stopped by user.";
212
+ }
213
+ /** Plain text out of a `tool_result` content block's own `content` (a string or
214
+ * an array of text/image blocks) — mirrors `extractText`'s shape-handling. */
215
+ function toolResultText(block) {
216
+ const content = block?.content;
217
+ if (typeof content === "string")
218
+ return content;
219
+ if (!Array.isArray(content))
220
+ return "";
221
+ return content
222
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
223
+ .map((part) => part.text)
224
+ .join("");
225
+ }
226
+ /** Sums per-model token usage (SDK's ModelUsage) into a single totals object. */
227
+ export function sumModelUsage(modelUsage) {
228
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
229
+ for (const u of Object.values(modelUsage)) {
230
+ input += u.inputTokens ?? 0;
231
+ output += u.outputTokens ?? 0;
232
+ cacheRead += u.cacheReadInputTokens ?? 0;
233
+ cacheWrite += u.cacheCreationInputTokens ?? 0;
234
+ }
235
+ return { input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite };
236
+ }
237
+ /**
238
+ * Pure mapping from the SDK's (experimental, unstable) get-usage response
239
+ * shape into our runtime-agnostic UsageSnapshot. Kept separate from
240
+ * ClaudeCodeSession.getUsage() so the mapping itself is unit-testable without
241
+ * spinning up a real SDK query.
242
+ */
243
+ export function mapUsageResponse(data) {
244
+ const session = data?.session;
245
+ const windows = [];
246
+ const rl = data?.rate_limits;
247
+ if (rl) {
248
+ const push = (label, w) => {
249
+ if (w)
250
+ windows.push({ label, utilizationPct: w.utilization ?? null, resetsAt: w.resets_at ?? null });
251
+ };
252
+ push("5-hour", rl.five_hour);
253
+ push("7-day", rl.seven_day);
254
+ push("7-day (OAuth apps)", rl.seven_day_oauth_apps);
255
+ push("7-day (Opus)", rl.seven_day_opus);
256
+ push("7-day (Sonnet)", rl.seven_day_sonnet);
257
+ for (const m of rl.model_scoped ?? [])
258
+ push(m.display_name, m);
259
+ }
260
+ return {
261
+ costUsd: session?.total_cost_usd,
262
+ tokens: session?.model_usage ? sumModelUsage(session.model_usage) : undefined,
263
+ plan: {
264
+ subscriptionType: data?.subscription_type ?? null,
265
+ windows,
266
+ },
267
+ };
268
+ }
269
+ function toModelInfo(model) {
270
+ // Shared mapping (src/runtime/normalize.ts); Claude models default to the
271
+ // anthropic provider when the record carries none.
272
+ return sharedToModelInfo(model, { defaultProvider: "anthropic" });
273
+ }
274
+ function cleanSessionName(value) {
275
+ return value
276
+ .replace(/[\r\n"'`]/g, " ")
277
+ .replace(/[\p{Control}]/gu, "")
278
+ .replace(/\s+/g, " ")
279
+ .trim()
280
+ .replace(/[.?!,:;\-–—]+$/g, "")
281
+ .slice(0, 60)
282
+ .trim();
283
+ }
284
+ /** The bearer we handed the SDK: the OAuth subscription token if present, else
285
+ * the API key. Used to tell whether the vault has rotated the credential since
286
+ * a query was spawned (the query's env is fixed at spawn — see spawnQuery). */
287
+ function authTokenFromEnv(env) {
288
+ return env.CLAUDE_CODE_OAUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || undefined;
289
+ }
290
+ function claudeUserContent(text, options) {
291
+ const images = options?.images ?? [];
292
+ if (!images.length)
293
+ return text;
294
+ return [
295
+ ...(text ? [{ type: "text", text }] : []),
296
+ ...images.map((image) => ({
297
+ type: "image",
298
+ source: {
299
+ type: "base64",
300
+ media_type: image.mimeType || "image/png",
301
+ data: image.data,
302
+ },
303
+ })),
304
+ ];
305
+ }
306
+ function claudeProjectDirs() {
307
+ return [
308
+ process.env.CLAUDE_CONFIG_DIR,
309
+ path.join(os.homedir(), ".claude"),
310
+ ].filter((value) => Boolean(value));
311
+ }
312
+ /**
313
+ * Root(s) to BULK-SCAN for native session discovery (issue #156) — narrower
314
+ * than claudeProjectDirs() above. That helper searches both CLAUDE_CONFIG_DIR
315
+ * and the default `~/.claude` when locating one already-known session id by
316
+ * name, which is harmless (a wrong root just doesn't have the file). Bulk
317
+ * discovery is different: unconditionally also listing `~/.claude` would leak
318
+ * unrelated sessions from the default store onto a node that was deliberately
319
+ * pointed at a non-default config dir. The real `claude` CLI's own
320
+ * CLAUDE_CONFIG_DIR handling is exclusive (it replaces the default, not adds
321
+ * to it), so discovery mirrors that: CLAUDE_CONFIG_DIR when set, else the
322
+ * default `~/.claude` — never both.
323
+ */
324
+ function claudeDiscoveryRoots() {
325
+ const custom = process.env.CLAUDE_CONFIG_DIR?.trim();
326
+ return [custom || path.join(os.homedir(), ".claude")];
327
+ }
328
+ function findClaudeTranscript(sessionId) {
329
+ const fileName = `${sessionId}.jsonl`;
330
+ for (const root of claudeProjectDirs()) {
331
+ const projects = path.join(root, "projects");
332
+ try {
333
+ for (const project of fs.readdirSync(projects, { withFileTypes: true })) {
334
+ if (!project.isDirectory())
335
+ continue;
336
+ const candidate = path.join(projects, project.name, fileName);
337
+ if (fs.existsSync(candidate))
338
+ return candidate;
339
+ }
340
+ }
341
+ catch {
342
+ // ignore missing/unreadable Claude stores
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+ function loadClaudeTranscript(sessionId) {
348
+ const file = findClaudeTranscript(sessionId);
349
+ if (!file)
350
+ return [];
351
+ const messages = [];
352
+ // Indices of "the session was restarted" notices (interruptedByShutdown). A
353
+ // restart the session *continued past* was recovered (a credential-reload
354
+ // re-drive, or a session the user resumed), so it's noise — we keep only a
355
+ // trailing one, where the session actually ended interrupted. See below.
356
+ const restartNoticeIdx = [];
357
+ try {
358
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
359
+ if (!line.trim())
360
+ continue;
361
+ const entry = JSON.parse(line);
362
+ const rawRole = entry?.message?.role ?? entry?.role;
363
+ const rawContent = entry?.message?.content ?? entry?.content;
364
+ // Interrupt markers aren't dropped — surface them as a system notice,
365
+ // labeled by cause (a real Stop vs. a teardown/restart) so history shows
366
+ // *why* a turn ended instead of a bare, confusing user bubble. This is the
367
+ // one place that survives a redeploy: interruptedByShutdown rides the
368
+ // on-disk entry, so even a new process can label it correctly.
369
+ if (rawRole === "user" && isInterruptText(rawContent)) {
370
+ messages.push({ role: "system", content: interruptNoticeText(entry), timestamp: new Date(entry?.timestamp ?? entry?.createdAt ?? Date.now()).getTime() });
371
+ if (entry?.interruptedByShutdown === true)
372
+ restartNoticeIdx.push(messages.length - 1);
373
+ continue;
374
+ }
375
+ // Drop model-only meta the CLI writes for itself (task-notifications,
376
+ // injected reminders, compaction summaries). Flags ride the entry; the
377
+ // text net catches the code paths that omit them. See DROP_META_TEXT.
378
+ if (hasMetaFlag(entry))
379
+ continue;
380
+ const role = rawRole;
381
+ if (role !== "user" && role !== "assistant")
382
+ continue;
383
+ const content = rawContent;
384
+ if (role === "user" && Array.isArray(content) && !content.some((block) => block?.type === "text" || block?.type === "tool_result"))
385
+ continue;
386
+ if (role === "user" && !(Array.isArray(content) && content.some((block) => block?.type === "tool_result")) && isDropMetaText(content))
387
+ continue;
388
+ if (role === "assistant" && Array.isArray(content) && !content.some((block) => block?.type === "text" || block?.type === "tool_use" || block?.type === "thinking"))
389
+ continue;
390
+ if (typeof content !== "string" && !Array.isArray(content))
391
+ continue;
392
+ messages.push({ role, content, timestamp: new Date(entry?.timestamp ?? entry?.createdAt ?? Date.now()).getTime() });
393
+ }
394
+ }
395
+ catch {
396
+ return dropRecoveredRestartNotices(messages, restartNoticeIdx);
397
+ }
398
+ return dropRecoveredRestartNotices(messages, restartNoticeIdx);
399
+ }
400
+ /** Drop "session was restarted" notices the transcript continued past (they were
401
+ * recovered — a credential-reload re-drive or a resumed session), keeping only a
402
+ * trailing one where the session actually ended interrupted. Genuine user Stops
403
+ * are never in `restartNoticeIdx`, so they're always kept. */
404
+ function dropRecoveredRestartNotices(messages, restartNoticeIdx) {
405
+ if (!restartNoticeIdx.length)
406
+ return messages;
407
+ const lastIdx = messages.length - 1;
408
+ const drop = new Set(restartNoticeIdx.filter((i) => i !== lastIdx));
409
+ return drop.size ? messages.filter((_, i) => !drop.has(i)) : messages;
410
+ }
411
+ /**
412
+ * Cheap, bounded per-file scan for native discovery (issue #156) — deliberately
413
+ * NOT loadClaudeTranscript: that reconstructs the full conversation, which is
414
+ * exactly the transcript CONTENT discovery must never carry. This reads only
415
+ * the first recorded `cwd` and a truncated first user prompt, stopping the
416
+ * moment both are found, plus the file's mtime as a last-activity proxy.
417
+ * Best-effort: any read/parse failure yields whatever was found so far (or
418
+ * undefined if the file itself is unreadable).
419
+ */
420
+ function scanClaudeSessionForDiscovery(file) {
421
+ let updatedAt;
422
+ try {
423
+ updatedAt = fs.statSync(file).mtimeMs;
424
+ }
425
+ catch {
426
+ return undefined;
427
+ }
428
+ let cwd;
429
+ let title;
430
+ try {
431
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
432
+ if (!line.trim())
433
+ continue;
434
+ if (cwd && title)
435
+ break;
436
+ let entry;
437
+ try {
438
+ entry = JSON.parse(line);
439
+ }
440
+ catch {
441
+ continue;
442
+ }
443
+ if (!cwd && typeof entry?.cwd === "string")
444
+ cwd = entry.cwd;
445
+ if (!title) {
446
+ const role = entry?.message?.role ?? entry?.role;
447
+ const content = entry?.message?.content ?? entry?.content;
448
+ if (role === "user" && !hasMetaFlag(entry) && !isInterruptText(content)) {
449
+ const text = typeof content === "string"
450
+ ? content
451
+ : Array.isArray(content)
452
+ ? content.find((b) => b?.type === "text")?.text
453
+ : undefined;
454
+ if (text?.trim())
455
+ title = text.trim().slice(0, 200);
456
+ }
457
+ }
458
+ }
459
+ }
460
+ catch {
461
+ // best-effort — whatever cwd/title were found before the failure still stand
462
+ }
463
+ return { cwd, updatedAt, title };
464
+ }
465
+ /**
466
+ * Enumerate Claude Code's on-disk sessions — from claudeDiscoveryRoots()
467
+ * (CLAUDE_CONFIG_DIR when set, else `~/.claude`, so a non-default provider
468
+ * home is honored and never mixed with the default store) — as bounded
469
+ * discovery metadata. Every session on disk has a stable id (the jsonl
470
+ * filename) Claude resumes by, so `resumable` is always true; `active` is a
471
+ * best-effort live-process check scoped to the session's own cwd. Best-effort
472
+ * throughout: an unreadable store yields fewer results, never a throw.
473
+ */
474
+ export function discoverNativeClaudeSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, CLAUDE_BIN_NAMES)) {
475
+ const out = [];
476
+ const seenIds = new Set();
477
+ for (const root of claudeDiscoveryRoots()) {
478
+ const projectsDir = path.join(root, "projects");
479
+ let projectEntries;
480
+ try {
481
+ projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
482
+ }
483
+ catch {
484
+ continue; // no store at this root — best-effort, try the next one
485
+ }
486
+ for (const project of projectEntries) {
487
+ if (!project.isDirectory())
488
+ continue;
489
+ const projectDir = path.join(projectsDir, project.name);
490
+ let files;
491
+ try {
492
+ files = fs.readdirSync(projectDir).filter((name) => name.endsWith(".jsonl"));
493
+ }
494
+ catch {
495
+ continue;
496
+ }
497
+ for (const fileName of files) {
498
+ const sessionId = fileName.slice(0, -".jsonl".length);
499
+ if (!sessionId || seenIds.has(sessionId))
500
+ continue;
501
+ seenIds.add(sessionId);
502
+ const meta = scanClaudeSessionForDiscovery(path.join(projectDir, fileName));
503
+ if (!meta)
504
+ continue;
505
+ out.push({
506
+ runtimeId: "claude-code-sdk",
507
+ ref: sessionId,
508
+ file: path.join(projectDir, fileName),
509
+ cwd: meta.cwd,
510
+ updatedAt: meta.updatedAt,
511
+ title: meta.title,
512
+ active: Boolean(meta.cwd) && hasLiveProcess(meta.cwd),
513
+ resumable: true,
514
+ });
515
+ }
516
+ }
517
+ }
518
+ return out.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
519
+ }
520
+ class ClaudeSession {
521
+ runtimeOptions;
522
+ cwd;
523
+ toolInterceptor;
524
+ query;
525
+ // Re-created on every spawnQuery: the previous SDK query owns the old queue, so
526
+ // a re-spawn (credential reload) must not share it. Not readonly for that reason.
527
+ input = new AsyncQueue();
528
+ emitter = new EventEmitter();
529
+ messages = [];
530
+ streaming = false;
531
+ name;
532
+ models = [];
533
+ currentModel;
534
+ desiredModel;
535
+ /** Token used to `resume` an existing session; undefined for a fresh one. */
536
+ resumeId;
537
+ /** The id we report; for new sessions we pin it via the SDK `sessionId` option. */
538
+ id;
539
+ currentText = "";
540
+ startedMessage = false;
541
+ runningTools = new Set();
542
+ /** The bearer baked into the *currently running* query's env at spawn. A turn
543
+ * compares this against a freshly-resolved vault token to decide whether a
544
+ * credential reload is worth it (see restartWithFreshCredential). */
545
+ spawnedToken;
546
+ /** The user content of the in-flight turn, kept so a mid-flight credential
547
+ * reload can re-drive the interrupted prompt into the re-spawned query (the
548
+ * failed turn never reached disk, so resume alone wouldn't replay it). */
549
+ inFlightPrompt;
550
+ /** One reactive credential reload per turn — a second 401 after a refresh means
551
+ * the fresh token was also rejected, so surface it instead of looping. */
552
+ reloadedThisTurn = false;
553
+ /** Guards restartWithFreshCredential against re-entrancy. */
554
+ reloading = false;
555
+ /** Set when *we* call abort() for a genuine user Stop, so the resulting
556
+ * "[Request interrupted by user]" marker is labeled "Stopped by user" with
557
+ * certainty rather than relying on the CLI's interruptedByShutdown flag.
558
+ * Cleared once the marker is consumed or the turn otherwise ends. */
559
+ userAbortPending = false;
560
+ /** Set when we tear the query down for a *credential reload*, which the CLI
561
+ * records as an interrupt even though the turn transparently re-drives and
562
+ * completes. Suppresses the resulting marker so it never flashes an
563
+ * "interrupted" notice. One-shot; cleared when consumed, on the next result,
564
+ * and on a real abort() (a user Stop must never be silenced by a stale flag). */
565
+ suppressNextInterrupt = false;
566
+ /** The agent's own slash commands for this session, learned from the SDK's
567
+ * system/init message (slash_commands + skills). Empty until the first turn's
568
+ * init arrives; getCommands() exposes them and a `runtime.commands` event lets
569
+ * the daemon re-advertise once they're known. */
570
+ commands = [];
571
+ constructor(runtimeOptions, cwd, toolInterceptor, resumeId) {
572
+ this.runtimeOptions = runtimeOptions;
573
+ this.cwd = cwd;
574
+ this.toolInterceptor = toolInterceptor;
575
+ this.resumeId = resumeId;
576
+ this.id = resumeId ?? randomUUID();
577
+ this.desiredModel = runtimeOptions.defaultModel;
578
+ if (resumeId)
579
+ this.messages = loadClaudeTranscript(resumeId);
580
+ }
581
+ /** Resume token handed back to the daemon; the session id doubles as the file. */
582
+ get sessionFile() {
583
+ return this.id;
584
+ }
585
+ get isStreaming() {
586
+ return this.streaming;
587
+ }
588
+ /**
589
+ * Resume this session in the `claude` CLI's interactive TUI. The SDK and CLI
590
+ * share one on-disk store (~/.claude/projects/<cwd>/<sessionId>.jsonl) and our
591
+ * session id *is* that sessionId, so `claude --resume <id>` in this worktree
592
+ * reopens the exact conversation. Returns null if the CLI is not installed.
593
+ * (Vault credentials are injected so the TUI uses the same auth as chat.)
594
+ */
595
+ async interactiveTuiCommand() {
596
+ if (!claudeCliAvailable())
597
+ return null;
598
+ const env = await this.resolveCredentialEnv().catch(() => ({}));
599
+ return { command: "claude", args: ["--resume", this.sessionFile], env };
600
+ }
601
+ getMessages() {
602
+ return this.messages;
603
+ }
604
+ getCommands() {
605
+ return this.commands;
606
+ }
607
+ subscribe(listener) {
608
+ this.emitter.on("event", listener);
609
+ return () => this.emitter.off("event", listener);
610
+ }
611
+ emit(event) {
612
+ this.emitter.emit("event", event);
613
+ }
614
+ async ensureStarted() {
615
+ if (this.query)
616
+ return;
617
+ // First open resumes the stored token (undefined for a brand-new session);
618
+ // a later credential reload resumes this.id (the conversation is on disk).
619
+ await this.spawnQuery(this.resumeId);
620
+ }
621
+ /**
622
+ * Spawn the SDK `query()` for this session and start consuming it. The vault
623
+ * credential is resolved *here*, not once ahead of time, and baked into the
624
+ * subprocess env; the bearer it produced is remembered on `spawnedToken` so a
625
+ * later turn can tell whether the vault has since rotated it. `resumeId` is the
626
+ * session to continue from: the stored resume token on a first open, or — when
627
+ * re-spawning after a credential refresh — this session's own id, since the
628
+ * conversation is already on disk by then.
629
+ *
630
+ * A fresh input queue is created on every spawn: the previous SDK query owns
631
+ * (and may hold a pending read on) the old one, so reusing it would let a
632
+ * re-driven prompt satisfy the dead query instead of the new one.
633
+ */
634
+ async spawnQuery(resumeId) {
635
+ const sdk = await (this.runtimeOptions.sdkLoader ?? loadSdk)();
636
+ this.input = new AsyncQueue();
637
+ // The SDK's "bypassPermissions" mode (what danger-full-access natively maps
638
+ // to) auto-approves every tool call *before* the canUseTool callback runs —
639
+ // so canUseTool never fires and the tool interceptor is skipped entirely,
640
+ // which silently disables Bivy's governance AND its AskUserQuestion question
641
+ // card (both ride the interceptor). Run those sessions in "default" mode and
642
+ // blanket-allow tools in canUseTool instead: identical unrestricted access,
643
+ // but the interceptor stays live.
644
+ const nativeMode = claudePermissionModeFor(sandboxTier(this.runtimeOptions.sandbox));
645
+ const fullAccess = nativeMode === "bypassPermissions";
646
+ const permissionMode = fullAccess ? "default" : nativeMode;
647
+ // Always defined (not gated on toolInterceptor): Bivy's interceptor may need
648
+ // to service a tool (e.g. answer an AskUserQuestion), so this hook must exist
649
+ // even when no other tools are gated.
650
+ const canUseTool = async (toolName, input, opts) => {
651
+ if (this.toolInterceptor) {
652
+ const decision = await this.toolInterceptor({ sessionId: this.id, toolName, input, signal: opts.signal });
653
+ // Bivy answered/serviced the call itself — feed the result back without
654
+ // running the tool. canUseTool has no success-result channel, so this
655
+ // rides the deny message (the SDK returns it as the tool result); the
656
+ // interceptor formats `result` as a plain statement, not an error.
657
+ if (decision && decision.handled) {
658
+ return { behavior: "deny", message: decision.result ?? "" };
659
+ }
660
+ if (decision && decision.block) {
661
+ return { behavior: "deny", message: decision.reason ?? `Blocked ${toolName}` };
662
+ }
663
+ }
664
+ // danger-full-access: no gating, just approve. Kept in "default" mode (not
665
+ // via bypassPermissions) so canUseTool still runs the interceptor above.
666
+ return { behavior: "allow", updatedInput: input };
667
+ };
668
+ // Start from the process env (the SDK's `env` replaces, not merges), layer in
669
+ // any configured extras, then the shared-vault credential so one login at the
670
+ // Bivy level serves this agent too. The vault wins over an ambient key.
671
+ const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env };
672
+ const credEnv = await this.resolveCredentialEnv();
673
+ Object.assign(env, credEnv);
674
+ this.spawnedToken = authTokenFromEnv(credEnv);
675
+ const options = {
676
+ cwd: this.cwd,
677
+ includePartialMessages: true,
678
+ // Native exec sandbox: map the node's tier to the SDK permission mode.
679
+ // "default" keeps canUseTool gating risky tools (and, for danger-full-
680
+ // access, blanket-allowing them — see fullAccess above); "plan" makes a
681
+ // read-only session. We never pass "bypassPermissions": it would skip
682
+ // canUseTool and silently break AskUserQuestion.
683
+ permissionMode,
684
+ canUseTool,
685
+ env,
686
+ };
687
+ if (resumeId)
688
+ options.resume = resumeId;
689
+ else
690
+ options.sessionId = this.id;
691
+ if (this.desiredModel)
692
+ options.model = this.desiredModel;
693
+ const q = sdk.query({ prompt: this.input, options });
694
+ this.query = q;
695
+ void this.consume(q);
696
+ // Best-effort: populate the model picker once the agent is up.
697
+ if (typeof q.supportedModels === "function") {
698
+ q.supportedModels()
699
+ .then((models) => {
700
+ this.models = (models ?? []).map(toModelInfo);
701
+ })
702
+ .catch(() => { });
703
+ }
704
+ }
705
+ /**
706
+ * Tear down the running query and re-spawn it resuming the same session, using
707
+ * a freshly-resolved vault credential (Pi's AuthStorage auto-refreshes an
708
+ * expired OAuth token, so re-resolving yields a live bearer). A long-lived
709
+ * query bakes its token in at spawn and cannot pick up a rotated one any other
710
+ * way, so restarting is the reload.
711
+ *
712
+ * Returns true when the vault produced a *different* token (the restart is
713
+ * worth it), false when it produced the same token or none — in which case the
714
+ * caller should surface the auth error rather than loop, since another attempt
715
+ * would fail identically (e.g. a revoked login, or a rotated refresh token this
716
+ * node lost the race for — the same cross-consumer race codex-auth.ts notes).
717
+ * Emits a "Refreshing credentials…" notice only when it actually restarts.
718
+ */
719
+ async restartWithFreshCredential() {
720
+ if (this.reloading)
721
+ return false;
722
+ this.reloading = true;
723
+ try {
724
+ const credEnv = await this.resolveCredentialEnv().catch(() => ({}));
725
+ const nextToken = authTokenFromEnv(credEnv);
726
+ if (!nextToken || nextToken === this.spawnedToken)
727
+ return false;
728
+ this.emit({ type: "session.notice", level: "info", message: "Refreshing credentials…" });
729
+ // This teardown makes the CLI write an interrupt marker, but the turn is
730
+ // re-driven and completes — so suppress that marker rather than blaming a
731
+ // phantom interrupt on the user (the "Refreshing credentials…" notice above
732
+ // already tells the human what happened).
733
+ this.suppressNextInterrupt = true;
734
+ this.teardownQuery();
735
+ await this.spawnQuery(this.resumeId ?? this.id);
736
+ return true;
737
+ }
738
+ finally {
739
+ this.reloading = false;
740
+ }
741
+ }
742
+ /** Close the current query and its input queue so a re-spawn resuming the same
743
+ * id can replace them. Unlike dispose() this leaves the session live (emitter,
744
+ * pending questions, message history intact) — only the transport is swapped. */
745
+ teardownQuery() {
746
+ const q = this.query;
747
+ this.query = undefined;
748
+ this.input.close();
749
+ if (q?.close) {
750
+ try {
751
+ q.close();
752
+ }
753
+ catch {
754
+ // ignore
755
+ }
756
+ }
757
+ }
758
+ /**
759
+ * Resolve the shared Anthropic credential (if any) and map it to the env vars
760
+ * the Claude Agent SDK accepts: ANTHROPIC_API_KEY for an API key, or
761
+ * CLAUDE_CODE_OAUTH_TOKEN for a Claude Pro/Max subscription token. Returns an
762
+ * empty object when no vault is wired or no credential is configured, so the
763
+ * SDK falls back to its own auth (ambient env / `claude` CLI login).
764
+ */
765
+ async resolveCredentialEnv() {
766
+ const store = this.runtimeOptions.credentials;
767
+ if (!store)
768
+ return {};
769
+ const provider = this.runtimeOptions.credentialProvider?.trim() || "anthropic";
770
+ let cred;
771
+ try {
772
+ cred = await store.getCredential(provider);
773
+ }
774
+ catch {
775
+ return {};
776
+ }
777
+ if (!cred)
778
+ return {};
779
+ const out = { ...(cred.env ?? {}) };
780
+ if (cred.kind === "oauth")
781
+ out.CLAUDE_CODE_OAUTH_TOKEN = cred.token;
782
+ else
783
+ out.ANTHROPIC_API_KEY = cred.token;
784
+ return out;
785
+ }
786
+ async consume(q) {
787
+ try {
788
+ for await (const message of q)
789
+ this.handle(message);
790
+ }
791
+ catch (error) {
792
+ this.streaming = false;
793
+ const raw = error instanceof Error ? error.message : String(error);
794
+ // Mid-flight credential reload: a long-lived query bakes its OAuth token in
795
+ // at spawn, so a turn that outlives the token fails here with a 401 even
796
+ // though the vault holds a freshly-refreshed one. Re-spawn once with the
797
+ // fresh credential and re-drive the interrupted prompt so the turn continues
798
+ // instead of dying. Bounded to one attempt per turn (reloadedThisTurn), only
799
+ // when a prompt is actually in flight, and only when the vault produced a
800
+ // *different* token (else restartWithFreshCredential returns false and we
801
+ // fall through to surfacing the error — no retry loop on a dead credential).
802
+ if (isAnthropicAuthError(raw) && !this.reloadedThisTurn && this.inFlightPrompt !== undefined) {
803
+ this.reloadedThisTurn = true;
804
+ if (await this.restartWithFreshCredential()) {
805
+ this.streaming = true;
806
+ this.input.push({ type: "user", message: { role: "user", content: this.inFlightPrompt }, parent_tool_use_id: null });
807
+ return; // the re-spawned query's consume() now drives the turn to completion.
808
+ }
809
+ }
810
+ // Emit session.error (the toast path) — agent_end's `error` field is not
811
+ // surfaced by the client, so without this a thrown SDK error (e.g. a 401)
812
+ // stopped the turn silently. Auth failures get sign-in guidance appended.
813
+ this.emit({ type: "session.error", error: describeAnthropicError(raw) });
814
+ this.emit({ type: "turn_end" });
815
+ this.emit({ type: "agent_end", error: raw });
816
+ }
817
+ }
818
+ beginMessage() {
819
+ if (this.startedMessage)
820
+ return;
821
+ this.startedMessage = true;
822
+ this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
823
+ }
824
+ handle(message) {
825
+ switch (message?.type) {
826
+ case "stream_event": {
827
+ const event = message.event;
828
+ if (event?.type === "content_block_delta" &&
829
+ event.delta?.type === "text_delta" &&
830
+ typeof event.delta.text === "string") {
831
+ this.beginMessage();
832
+ this.currentText += event.delta.text;
833
+ this.emit({ type: "message_update", message: { role: "assistant", content: this.currentText } });
834
+ }
835
+ break;
836
+ }
837
+ case "partial_assistant": {
838
+ const text = extractText(message.message);
839
+ if (text) {
840
+ this.beginMessage();
841
+ this.currentText = text;
842
+ this.emit({ type: "message_update", message: { role: "assistant", content: text } });
843
+ }
844
+ break;
845
+ }
846
+ case "assistant": {
847
+ // Compaction summaries and other meta assistant turns are for the model,
848
+ // not the human — never persist or surface them as chat.
849
+ if (hasMetaFlag(message))
850
+ break;
851
+ const model = message.message?.model;
852
+ if (model)
853
+ this.currentModel = toModelInfo({ id: model });
854
+ const content = Array.isArray(message.message?.content) ? message.message.content : [];
855
+ for (const block of content) {
856
+ if (block?.type === "tool_use") {
857
+ this.emit({ type: "tool_call", toolName: block.name, input: block.input, toolUseId: block.id });
858
+ }
859
+ }
860
+ const text = extractText(message.message);
861
+ // Persist the full assistant turn — text, reasoning, and tool_use blocks —
862
+ // so re-opening a session shows what led to the answer, not just the final
863
+ // prose. Keep the content array intact when present so the client can pair
864
+ // tool_use blocks with the tool_result messages below; otherwise the plain
865
+ // text is enough. The live stream still surfaces text via message_end.
866
+ if (content.length || text) {
867
+ this.messages.push({ role: "assistant", content: content.length ? content : text, timestamp: Date.now() });
868
+ }
869
+ if (text) {
870
+ this.beginMessage();
871
+ const finalized = { role: "assistant", content: text, timestamp: Date.now() };
872
+ this.emit({ type: "message_update", message: finalized });
873
+ this.emit({ type: "message_end", message: finalized });
874
+ }
875
+ this.startedMessage = false;
876
+ this.currentText = "";
877
+ break;
878
+ }
879
+ case "user": {
880
+ // The SDK echoes tool results back as user-role messages holding
881
+ // tool_result blocks. Persist those (matched to the tool_use blocks above
882
+ // by tool_use_id) so a re-opened transcript can show each call's output.
883
+ // Guard on tool_result so we never double-store the user's own prompt,
884
+ // which prompt() already appended.
885
+ const userContent = message.message?.content;
886
+ const toolResults = Array.isArray(userContent) ? userContent.filter((b) => b?.type === "tool_result") : [];
887
+ if (toolResults.length) {
888
+ this.messages.push({ role: "user", content: userContent, timestamp: Date.now() });
889
+ // Without this, a tool call's activity card in the transcript never
890
+ // learns its call finished — nothing else translates this message
891
+ // into a "result"-kind event, so `applyStreamEvent` (packages/core/
892
+ // src/store.ts) falls through its default case and the card's status
893
+ // stays "running" (spinner) for the rest of the session. `tool_progress`
894
+ // pings cover *some* long-running tools but every tool completes via
895
+ // this echo, so it's the one path that must flip every card to "done".
896
+ for (const block of toolResults) {
897
+ this.runningTools.delete(String(block.tool_use_id));
898
+ this.emit({ type: "tool_result", toolUseId: block.tool_use_id, result: toolResultText(block), isError: Boolean(block.is_error) });
899
+ }
900
+ this.emit({ type: "user", raw: message });
901
+ break;
902
+ }
903
+ // Interrupt marker: surface a system notice (never a user bubble),
904
+ // labeled by cause. If *we* just called abort() for a real user Stop
905
+ // (userAbortPending), that's authoritative; otherwise fall back to the
906
+ // CLI's interruptedByShutdown flag to tell a teardown/restart apart from
907
+ // a genuine Stop. Persist it (role:"system") so it survives reopen, and
908
+ // emit a live notice so it shows immediately.
909
+ if (isInterruptText(userContent)) {
910
+ // A credential-reload teardown produced this marker; the turn re-drives
911
+ // and completes transparently, so a notice would just be noise.
912
+ if (this.suppressNextInterrupt) {
913
+ this.suppressNextInterrupt = false;
914
+ break;
915
+ }
916
+ const notice = interruptNoticeText(message, this.userAbortPending);
917
+ this.userAbortPending = false;
918
+ this.messages.push({ role: "system", content: notice, timestamp: Date.now() });
919
+ this.emit({ type: "session.notice", level: "info", message: notice });
920
+ break;
921
+ }
922
+ // Otherwise: the human's own prompt echo, or a model-only meta turn
923
+ // (injected reminder / task-notification). Drop meta so it never renders
924
+ // as a chat bubble; forward a real prompt echo unchanged.
925
+ if (hasMetaFlag(message) || isDropMetaText(userContent))
926
+ break;
927
+ this.emit({ type: "user", raw: message });
928
+ break;
929
+ }
930
+ case "tool_progress": {
931
+ // The SDK's SDKToolProgressMessage carries no `status`/`output` field —
932
+ // it's purely a still-running elapsed-time ping for long-lived tools
933
+ // (the previous `message.status === "running"` check always read
934
+ // `undefined`, so every ping was mis-treated as completion, prematurely
935
+ // flipping the activity card to "done" mid-tool). Completion is now
936
+ // reported once, reliably, by the tool_result echo in `case "user"`
937
+ // above — this case only ever means "still running".
938
+ const toolUseId = message.tool_use_id;
939
+ const type = this.runningTools.has(toolUseId) ? "tool_execution_update" : "tool_execution_start";
940
+ this.runningTools.add(toolUseId);
941
+ this.emit({ type, toolName: message.tool_name, toolUseId, input: { elapsedSeconds: message.elapsed_time_seconds } });
942
+ break;
943
+ }
944
+ case "result": {
945
+ this.streaming = false;
946
+ this.startedMessage = false;
947
+ this.currentText = "";
948
+ // The turn ended; clear both interrupt flags so a stale one can't
949
+ // mislabel or wrongly suppress a later interrupt.
950
+ this.userAbortPending = false;
951
+ this.suppressNextInterrupt = false;
952
+ // Turn completed — the prompt is on disk now, so drop the copy kept for a
953
+ // mid-flight reload re-drive (see inFlightPrompt / consume's catch).
954
+ this.inFlightPrompt = undefined;
955
+ if (message.subtype && message.subtype !== "success") {
956
+ this.emit({ type: "tool_result", error: message.subtype, message: message.result });
957
+ }
958
+ this.emit({ type: "turn_end" });
959
+ // Cost/token/plan-quota totals are read via getUsage() (backed by the
960
+ // SDK's usage_EXPERIMENTAL... control request), not forwarded here.
961
+ this.emit({ type: "agent_end", subtype: message.subtype });
962
+ break;
963
+ }
964
+ case "system": {
965
+ // The SDK's system/init reports this session's available slash commands
966
+ // (built-ins, custom .claude/commands, plugins) and skills. Capture them
967
+ // so getCommands() can offer them in the composer; the SDK interprets a
968
+ // leading-slash prompt itself, so forwarding "/name" from chat runs it.
969
+ // They're only known once init arrives (after the first turn starts), so
970
+ // emit `runtime.commands` to let the daemon re-advertise capabilities.
971
+ if (message?.subtype === "init") {
972
+ const next = claudeCommandsFromInit(message);
973
+ if (next.length && !sameCommands(next, this.commands)) {
974
+ this.commands = next;
975
+ this.emit({ type: "runtime.commands", commands: next });
976
+ }
977
+ }
978
+ // Task-completion notifications and compaction boundaries are injected
979
+ // for the model, not the human — don't forward them as chat. (init still
980
+ // falls through so its capabilities/commands reach the client.)
981
+ if (message?.subtype === "task_notification" || message?.subtype === "compact_boundary")
982
+ break;
983
+ this.emit({ type: String(message?.type ?? "unknown"), raw: message });
984
+ break;
985
+ }
986
+ default:
987
+ // user/tool-result echoes, status, etc. — forwarded for debugging but
988
+ // not part of busy/idle tracking.
989
+ this.emit({ type: String(message?.type ?? "unknown"), raw: message });
990
+ }
991
+ }
992
+ async prompt(text, options) {
993
+ const prompt = text.trim();
994
+ const content = claudeUserContent(prompt, options);
995
+ const hasImages = Boolean(options?.images?.length);
996
+ if (!prompt && !hasImages)
997
+ return;
998
+ // Credential preflight (first turn only): if no Anthropic credential will
999
+ // reach the SDK, surface an actionable message instead of letting it spawn
1000
+ // and fail its first request with an opaque `401 Unauthorized`.
1001
+ if (!this.query) {
1002
+ const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv().catch(() => ({}))) };
1003
+ const preflightError = anthropicCredentialPreflight(env);
1004
+ if (preflightError) {
1005
+ this.messages.push({ role: "user", content: hasImages ? content : prompt, timestamp: Date.now() });
1006
+ this.emit({ type: "agent_start" });
1007
+ this.emit({ type: "turn_start" });
1008
+ this.emit({ type: "session.error", error: preflightError });
1009
+ this.emit({ type: "turn_end" });
1010
+ this.emit({ type: "agent_end" });
1011
+ return;
1012
+ }
1013
+ }
1014
+ await this.ensureStarted();
1015
+ // Proactive reload at the turn boundary: the query's bearer is fixed at spawn,
1016
+ // so if the vault has rotated it since (commonly: a parallel session refreshed
1017
+ // the shared OAuth token, or it simply expired while this session sat idle),
1018
+ // restart now with the fresh one instead of spending a stale token and 401ing.
1019
+ // Only between turns (never mid-stream — that would drop an in-flight turn),
1020
+ // and a no-op unless the token actually changed (see restartWithFreshCredential).
1021
+ if (this.query && !this.streaming)
1022
+ await this.restartWithFreshCredential();
1023
+ this.messages.push({ role: "user", content: hasImages ? content : prompt, timestamp: Date.now() });
1024
+ this.reloadedThisTurn = false;
1025
+ this.inFlightPrompt = content;
1026
+ this.streaming = true;
1027
+ this.emit({ type: "agent_start" });
1028
+ this.emit({ type: "turn_start" });
1029
+ this.input.push({ type: "user", message: { role: "user", content }, parent_tool_use_id: null });
1030
+ }
1031
+ async abort() {
1032
+ // Mark this as a genuine, user-initiated Stop so the interrupt marker the
1033
+ // CLI is about to write is labeled with certainty (not via the shutdown
1034
+ // flag). Clear any stale credential-reload suppression — a user Stop must
1035
+ // always be shown, never silenced.
1036
+ this.userAbortPending = true;
1037
+ this.suppressNextInterrupt = false;
1038
+ if (this.query?.interrupt) {
1039
+ try {
1040
+ await this.query.interrupt();
1041
+ }
1042
+ catch {
1043
+ // Best-effort; the stream may already be idle.
1044
+ }
1045
+ }
1046
+ this.streaming = false;
1047
+ }
1048
+ dispose() {
1049
+ // query.close() aborts the SDK's in-flight canUseTool control requests,
1050
+ // whose AbortSignal is forwarded to Bivy's tool interceptor — so a pending
1051
+ // AskUserQuestion (owned by the daemon's QuestionManager) settles via that
1052
+ // abort. The daemon also cancels a session's questions on teardown as a
1053
+ // belt-and-suspenders (see server.ts), so nothing depends on this alone.
1054
+ this.input.close();
1055
+ if (this.query?.close) {
1056
+ try {
1057
+ this.query.close();
1058
+ }
1059
+ catch {
1060
+ // ignore
1061
+ }
1062
+ }
1063
+ this.emitter.removeAllListeners();
1064
+ }
1065
+ getModels() {
1066
+ // Before the query is up (or if supportedModels() failed/returned nothing)
1067
+ // fall back to the known lineup so the picker is never empty.
1068
+ return this.models.length ? this.models : FALLBACK_MODELS;
1069
+ }
1070
+ getCurrentModel() {
1071
+ if (this.currentModel)
1072
+ return this.currentModel;
1073
+ if (this.desiredModel)
1074
+ return toModelInfo({ id: this.desiredModel });
1075
+ return undefined;
1076
+ }
1077
+ async setModel(provider, id) {
1078
+ this.desiredModel = id;
1079
+ this.currentModel = toModelInfo({ provider, id });
1080
+ if (this.query?.setModel)
1081
+ await this.query.setModel(id);
1082
+ }
1083
+ /**
1084
+ * Cost/token totals plus, for a claude.ai OAuth session, plan rate-limit
1085
+ * utilization (how much of the 5-hour/7-day window is used). Backed by the
1086
+ * SDK's `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` —
1087
+ * explicitly unstable, so this fails soft (returns undefined) rather than
1088
+ * ever breaking the session if the shape changes or the call throws.
1089
+ */
1090
+ async getUsage() {
1091
+ const fn = this.query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
1092
+ if (typeof fn !== "function")
1093
+ return undefined;
1094
+ try {
1095
+ return mapUsageResponse(await fn.call(this.query));
1096
+ }
1097
+ catch {
1098
+ return undefined;
1099
+ }
1100
+ }
1101
+ getName() {
1102
+ return this.name;
1103
+ }
1104
+ setName(name) {
1105
+ this.name = name;
1106
+ }
1107
+ async suggestName(firstPrompt) {
1108
+ const prompt = firstPrompt.trim();
1109
+ if (!prompt)
1110
+ return undefined;
1111
+ const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...(await this.resolveCredentialEnv()) };
1112
+ const apiKey = env.ANTHROPIC_API_KEY || env.CLAUDE_CODE_OAUTH_TOKEN;
1113
+ if (!apiKey)
1114
+ return undefined;
1115
+ // Claude Pro/Max OAuth tokens are only authorized for Claude Code: a raw
1116
+ // /v1/messages call whose first system block isn't the Claude Code identity
1117
+ // is rejected (non-200), which silently dropped session naming back to the
1118
+ // first-line fallback. API keys have no such restriction. Send the identity
1119
+ // as the first system block for OAuth so the naming instruction survives.
1120
+ const useOAuth = !env.ANTHROPIC_API_KEY;
1121
+ const namingInstruction = "Name chat sessions from the user's entire first message, not just its first line. Return only a concise title, 2-6 words. No quotes, punctuation, prefixes, or explanations.";
1122
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
1123
+ method: "POST",
1124
+ headers: {
1125
+ "content-type": "application/json",
1126
+ "anthropic-version": "2023-06-01",
1127
+ ...(useOAuth
1128
+ ? { authorization: `Bearer ${apiKey}`, "anthropic-beta": "oauth-2025-04-20" }
1129
+ : { "x-api-key": apiKey }),
1130
+ },
1131
+ body: JSON.stringify({
1132
+ model: this.desiredModel || "claude-3-5-haiku-latest",
1133
+ max_tokens: 24,
1134
+ temperature: 0.2,
1135
+ system: useOAuth
1136
+ ? [
1137
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
1138
+ { type: "text", text: namingInstruction },
1139
+ ]
1140
+ : namingInstruction,
1141
+ messages: [{ role: "user", content: `Create a short title for this coding-agent session using the full first message below:\n\n${prompt.slice(0, 4000)}` }],
1142
+ }),
1143
+ });
1144
+ if (!response.ok)
1145
+ return undefined;
1146
+ const json = (await response.json());
1147
+ const text = (json.content ?? []).filter((part) => part.type === "text").map((part) => part.text ?? "").join(" ");
1148
+ return cleanSessionName(text) || undefined;
1149
+ }
1150
+ }
1151
+ export class ClaudeCodeRuntime {
1152
+ options;
1153
+ id = "claude-code-sdk";
1154
+ displayName = "Claude Code SDK";
1155
+ capabilities = {
1156
+ toolInterception: true,
1157
+ modelSelection: true,
1158
+ packages: false,
1159
+ resume: true,
1160
+ fork: true,
1161
+ // Only offer the chat<->TUI hand-off when the `claude` CLI is on PATH.
1162
+ interactiveTui: claudeCliAvailable(),
1163
+ usageReporting: true,
1164
+ // The on-disk jsonl transcript can be exported and re-materialised on another
1165
+ // node under a fresh session id, so a claude->claude fork is full fidelity.
1166
+ forkTransport: true,
1167
+ // Claude Code ignores the streamingBehavior hint entirely — a mid-turn
1168
+ // prompt always re-enters the live input queue and behaves like an
1169
+ // immediate steer, regardless of what's asked for. There is no real
1170
+ // "followUp" (defer until the turn ends) here, so only advertise steer.
1171
+ streamingBehaviors: ["steer"],
1172
+ // Claude sessions started outside Bivy (a bare `claude` in a terminal) are
1173
+ // discoverable across claudeProjectDirs() and every discovered session can
1174
+ // be adopted with a true native resume (see discoverNativeSessions below).
1175
+ nativeSessionDiscovery: true,
1176
+ nativeSessionAdoption: true,
1177
+ };
1178
+ sessions = [];
1179
+ constructor(options = {}) {
1180
+ this.options = options;
1181
+ }
1182
+ /** Claude Code runs Anthropic models; the authoritative list comes from the
1183
+ * live query per session, so the session-less catalog is the known lineup. */
1184
+ listCatalog() {
1185
+ return [{ id: "anthropic", name: "Anthropic", oauth: true, models: FALLBACK_MODELS }];
1186
+ }
1187
+ async createSession(options) {
1188
+ const session = new ClaudeSession(this.options, options.workspace, options.toolInterceptor);
1189
+ this.sessions.push(session);
1190
+ return { session };
1191
+ }
1192
+ async openSession(options) {
1193
+ const session = new ClaudeSession(this.options, options.workspace, options.toolInterceptor, options.sessionFile);
1194
+ this.sessions.push(session);
1195
+ return {
1196
+ session,
1197
+ warning: "Resumed Claude Code session history is replayed by the agent on the next turn; prior messages are not preloaded.",
1198
+ };
1199
+ }
1200
+ /**
1201
+ * Read a session's transcript from Claude's on-disk store without constructing
1202
+ * a live session. `sessionFile` is the resume token — a Claude session id (the
1203
+ * runtime resumes by id, not path). This is exactly the read a resumed
1204
+ * ClaudeSession does in its constructor (`this.messages = loadClaudeTranscript`),
1205
+ * so the fast open path renders identically to the eventual live session. Empty
1206
+ * when the transcript isn't on disk (e.g. BIVY_CLAUDE_SESSIONS_DIR unset), which
1207
+ * the caller treats as "nothing to fast-paint" and falls back to a full open.
1208
+ */
1209
+ readMessages(sessionFile) {
1210
+ try {
1211
+ return loadClaudeTranscript(sessionFile);
1212
+ }
1213
+ catch {
1214
+ return undefined;
1215
+ }
1216
+ }
1217
+ /** See discoverNativeClaudeSessions — enumerates Claude sessions on this node
1218
+ * that Bivy didn't start, as bounded metadata (issue #156). */
1219
+ discoverNativeSessions() {
1220
+ try {
1221
+ return discoverNativeClaudeSessions();
1222
+ }
1223
+ catch {
1224
+ return [];
1225
+ }
1226
+ }
1227
+ /**
1228
+ * Export a claude session for a same-runtime fork: the raw jsonl transcript
1229
+ * (Claude's own on-disk format), located by session id across the project
1230
+ * stores. Undefined when the transcript isn't on disk. Reconstructed by
1231
+ * `importForFork` on the destination node.
1232
+ */
1233
+ exportForFork(sessionFile) {
1234
+ const file = findClaudeTranscript(sessionFile);
1235
+ if (!file)
1236
+ return undefined;
1237
+ try {
1238
+ const jsonl = fs.readFileSync(file, "utf8");
1239
+ return { runtimeId: this.id, kind: "claude-jsonl", data: { jsonl, sourceId: sessionFile } };
1240
+ }
1241
+ catch {
1242
+ return undefined;
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Materialise an exported claude transcript into a fresh session on this node.
1247
+ * Writes the jsonl under the destination cwd's project dir with a NEW session
1248
+ * id (rewriting each entry's `sessionId`/`cwd` so `--resume <id>` and the SDK's
1249
+ * store both see a self-consistent conversation), and returns that id as the
1250
+ * resume ref. The source session is never touched.
1251
+ */
1252
+ async importForFork(payload, ctx) {
1253
+ if (payload.runtimeId !== this.id || payload.kind !== "claude-jsonl") {
1254
+ throw new Error(`claude.importForFork: unexpected payload ${payload.runtimeId}/${payload.kind}`);
1255
+ }
1256
+ const jsonl = String(payload.data?.jsonl ?? "");
1257
+ const newId = randomUUID();
1258
+ const cwd = ctx.cwd || ctx.workspace;
1259
+ // Claude encodes the cwd into the project-dir name by replacing every
1260
+ // non-alphanumeric char with "-", e.g. "/home/u/p" -> "-home-u-p".
1261
+ const projectSlug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
1262
+ const root = claudeProjectDirs()[0] ?? path.join(os.homedir(), ".claude");
1263
+ const projectDir = path.join(root, "projects", projectSlug);
1264
+ fs.mkdirSync(projectDir, { recursive: true });
1265
+ const rewritten = jsonl
1266
+ .split(/\r?\n/)
1267
+ .filter((line) => line.trim())
1268
+ .map((line) => {
1269
+ try {
1270
+ const entry = JSON.parse(line);
1271
+ entry.sessionId = newId;
1272
+ if (entry.cwd)
1273
+ entry.cwd = cwd;
1274
+ return JSON.stringify(entry);
1275
+ }
1276
+ catch {
1277
+ return line; // preserve any line we can't parse rather than dropping it
1278
+ }
1279
+ })
1280
+ .join("\n");
1281
+ fs.writeFileSync(path.join(projectDir, `${newId}.jsonl`), rewritten ? `${rewritten}\n` : "");
1282
+ return { sessionFile: newId, id: newId };
1283
+ }
1284
+ async listSessions() {
1285
+ const dir = this.options.sessionsDir;
1286
+ if (dir) {
1287
+ try {
1288
+ const sdk = await loadSdk();
1289
+ if (typeof sdk.listSessions === "function") {
1290
+ const sessions = await sdk.listSessions({ dir });
1291
+ return (sessions ?? []).map((s) => {
1292
+ const id = s.sessionId ?? s.id;
1293
+ const open = this.sessions.find((session) => session.id === id || session.sessionFile === s.path);
1294
+ return {
1295
+ id,
1296
+ path: s.path,
1297
+ cwd: s.cwd ?? dir,
1298
+ name: open?.getName() ?? s.name ?? s.title,
1299
+ created: s.created ?? s.createdAt,
1300
+ modified: s.modified ?? s.updatedAt,
1301
+ messageCount: s.messageCount,
1302
+ firstMessage: s.firstMessage ?? s.summary,
1303
+ };
1304
+ });
1305
+ }
1306
+ }
1307
+ catch {
1308
+ // Fall through to in-memory sessions if the SDK can't list from disk.
1309
+ }
1310
+ }
1311
+ return this.sessions.map((session) => ({
1312
+ id: session.id,
1313
+ cwd: session.cwd,
1314
+ name: session.getName(),
1315
+ messageCount: session.getMessages().length,
1316
+ }));
1317
+ }
1318
+ /**
1319
+ * Delete a session from Claude's own on-disk store. Without this, deleting a
1320
+ * Claude session in the app only clears Bivy's metadata + its sessionsDir file
1321
+ * — the transcript still sits in Claude's store (BIVY_CLAUDE_SESSIONS_DIR /
1322
+ * `~/.claude/projects/<cwd>/<id>.jsonl`), so the next listSessions re-surfaces
1323
+ * it and the sidebar row reappears. We remove the exact `.jsonl` the SDK lists
1324
+ * from so the delete finally sticks.
1325
+ */
1326
+ async deleteSession(sessionId, sessionFile) {
1327
+ // Drop (and tear down) any in-memory handle so the fallback branch of
1328
+ // listSessions (used when no sessionsDir is set) can't re-advertise it, and a
1329
+ // still-live child doesn't outlive the delete.
1330
+ for (let i = this.sessions.length - 1; i >= 0; i--) {
1331
+ const s = this.sessions[i];
1332
+ if (s.id === sessionId || (sessionFile && s.sessionFile === sessionFile)) {
1333
+ try {
1334
+ s.dispose();
1335
+ }
1336
+ catch { /* already disposed by the caller's close */ }
1337
+ this.sessions.splice(i, 1);
1338
+ }
1339
+ }
1340
+ // Prefer the exact path the SDK reports (that's the file it would re-read on
1341
+ // the next list); fall back to locating the transcript by id across Claude's
1342
+ // project stores. `sessionFile` is Claude's resume token (an id), not a path,
1343
+ // so it can't be unlinked directly.
1344
+ let file;
1345
+ try {
1346
+ file = (await this.listSessions()).find((s) => s.id === sessionId)?.path;
1347
+ }
1348
+ catch {
1349
+ // SDK list failed — fall through to the on-disk id lookup below.
1350
+ }
1351
+ file ??= findClaudeTranscript(sessionId);
1352
+ if (!file)
1353
+ return false;
1354
+ try {
1355
+ await fs.promises.unlink(file);
1356
+ return true;
1357
+ }
1358
+ catch (error) {
1359
+ if (error?.code === "ENOENT")
1360
+ return false;
1361
+ throw error;
1362
+ }
1363
+ }
1364
+ }