@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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
|
+
}
|