@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,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Pi creates its own session id after the native TUI starts, so `bivy run pi`
|
|
5
|
+
* cannot pin one on the terminal metadata the way Claude can. Correlate the PTY
|
|
6
|
+
* with Pi's durable session index by workspace and creation time instead. */
|
|
7
|
+
export function discoverPiSessionForCwd(sessions, cwd, since) {
|
|
8
|
+
const wanted = normalizePath(cwd);
|
|
9
|
+
if (!wanted || !Number.isFinite(since))
|
|
10
|
+
return undefined;
|
|
11
|
+
// The PTY process is spawned just before TerminalManager stamps createdAt. On
|
|
12
|
+
// a fast machine Pi can write its session header a few milliseconds earlier,
|
|
13
|
+
// so allow a small clock/scheduling margin rather than requiring >= exactly.
|
|
14
|
+
const earliest = since - 10_000;
|
|
15
|
+
return sessions
|
|
16
|
+
.filter((session) => normalizePath(session.cwd || "") === wanted)
|
|
17
|
+
.map((session) => ({ session, createdAt: timestamp(session.created) }))
|
|
18
|
+
.filter(({ createdAt }) => createdAt >= earliest)
|
|
19
|
+
.sort((a, b) => {
|
|
20
|
+
const distance = Math.abs(a.createdAt - since) - Math.abs(b.createdAt - since);
|
|
21
|
+
if (distance !== 0)
|
|
22
|
+
return distance;
|
|
23
|
+
return timestamp(b.session.modified) - timestamp(a.session.modified);
|
|
24
|
+
})[0]?.session;
|
|
25
|
+
}
|
|
26
|
+
function normalizePath(value) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return "";
|
|
29
|
+
const normalized = path.resolve(value);
|
|
30
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
31
|
+
}
|
|
32
|
+
function timestamp(value) {
|
|
33
|
+
if (value instanceof Date)
|
|
34
|
+
return value.getTime();
|
|
35
|
+
if (typeof value === "number")
|
|
36
|
+
return Number.isFinite(value) ? value : 0;
|
|
37
|
+
if (typeof value === "string") {
|
|
38
|
+
const parsed = Date.parse(value);
|
|
39
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Pi adapter — the first concrete AgentRuntime.
|
|
4
|
+
//
|
|
5
|
+
// This is the *only* place (besides native-pi.ts and integrations/) that imports
|
|
6
|
+
// the Pi SDK for session logic. It maps Pi's AgentSession/SessionManager onto the
|
|
7
|
+
// runtime-agnostic interface in ./types.ts so the daemon never sees a Pi type.
|
|
8
|
+
//
|
|
9
|
+
// The guardian/approval hook is wired here: a generic `toolInterceptor` passed in
|
|
10
|
+
// OpenSessionOptions is adapted to Pi's `pi.on("tool_call")` extension event.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
15
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { createPiModelRuntime } from "./pi-oauth.js";
|
|
17
|
+
import { toModelInfo as sharedToModelInfo } from "./normalize.js";
|
|
18
|
+
import { provisionPiAuthJson } from "./credential-provisioning.js";
|
|
19
|
+
import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
|
|
20
|
+
/**
|
|
21
|
+
* Extract Pi's own slash commands from a live AgentSession: extension commands
|
|
22
|
+
* (`pi.registerCommand`, exposed via `extensionRunner.getRegisteredCommands()`),
|
|
23
|
+
* prompt templates (`promptTemplates`), and skills (`/skill:name`). Pi stores
|
|
24
|
+
* names without a leading slash, so we normalize to "/name"; duplicates are
|
|
25
|
+
* dropped (first wins). Reads the SDK through loose accessors and swallows any
|
|
26
|
+
* shape mismatch — this is display-only, so it degrades to an empty list rather
|
|
27
|
+
* than throwing. Pure and exported so it's unit-testable without a live agent.
|
|
28
|
+
*/
|
|
29
|
+
export function piSessionCommands(session) {
|
|
30
|
+
try {
|
|
31
|
+
const s = session;
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const out = [];
|
|
34
|
+
const add = (rawName, description) => {
|
|
35
|
+
const base = typeof rawName === "string" ? rawName.trim().replace(/^\/+/, "") : "";
|
|
36
|
+
if (!base)
|
|
37
|
+
return;
|
|
38
|
+
const name = `/${base}`;
|
|
39
|
+
if (seen.has(name))
|
|
40
|
+
return;
|
|
41
|
+
seen.add(name);
|
|
42
|
+
out.push(typeof description === "string" && description.trim() ? { name, description: description.trim() } : { name });
|
|
43
|
+
};
|
|
44
|
+
const ext = s?.extensionRunner?.getRegisteredCommands?.();
|
|
45
|
+
if (Array.isArray(ext))
|
|
46
|
+
for (const c of ext)
|
|
47
|
+
add(c?.invocationName, c?.description);
|
|
48
|
+
const templates = s?.promptTemplates;
|
|
49
|
+
if (Array.isArray(templates))
|
|
50
|
+
for (const t of templates)
|
|
51
|
+
add(t?.name, t?.description);
|
|
52
|
+
const skills = s?._resourceLoader?.getSkills?.()?.skills;
|
|
53
|
+
if (Array.isArray(skills))
|
|
54
|
+
for (const sk of skills)
|
|
55
|
+
add(`skill:${sk?.name}`, sk?.description);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
/**
|
|
64
|
+
* The bundled pi CLI (its interactive TUI). Mirrors native-pi.ts: honour the
|
|
65
|
+
* release override, else resolve the dependency next to the repo root (this file
|
|
66
|
+
* lives at {src,dist}/runtime/pi.js, so ../.. is the package root).
|
|
67
|
+
*/
|
|
68
|
+
function resolvePiCli() {
|
|
69
|
+
return process.env.BIVY_PI_CLI || path.resolve(runtimeDir, "..", "..", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
|
|
70
|
+
}
|
|
71
|
+
function toModelInfo(model, configured) {
|
|
72
|
+
// Shared mapping (src/runtime/normalize.ts). Pi models always carry
|
|
73
|
+
// provider/id/name, so the lenient fallbacks are no-ops here; `input` and
|
|
74
|
+
// `configured` pass through as before.
|
|
75
|
+
const info = sharedToModelInfo(model, configured != null ? { configured } : {});
|
|
76
|
+
if (info.input === undefined)
|
|
77
|
+
info.input = model.input;
|
|
78
|
+
return info;
|
|
79
|
+
}
|
|
80
|
+
function cleanSessionName(value) {
|
|
81
|
+
return value
|
|
82
|
+
.replace(/[\r\n"'`]/g, " ")
|
|
83
|
+
.replace(/[\p{Control}]/gu, "")
|
|
84
|
+
.replace(/\s+/g, " ")
|
|
85
|
+
.trim()
|
|
86
|
+
.replace(/[.?!,:;\-–—]+$/g, "")
|
|
87
|
+
.slice(0, 60)
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
/** Builds the Pi extension that bridges tool calls to a generic interceptor. */
|
|
91
|
+
function guardianFactory(sessionId, interceptor) {
|
|
92
|
+
return function guardian(pi) {
|
|
93
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
94
|
+
if (!interceptor)
|
|
95
|
+
return;
|
|
96
|
+
const decision = await interceptor({ sessionId, toolName: event.toolName, input: event.input, signal: ctx.signal });
|
|
97
|
+
// Bivy serviced the call itself (e.g. answered an AskUserQuestion): don't
|
|
98
|
+
// run the tool; hand the result back. Pi's tool_call hook can only supply
|
|
99
|
+
// content through the block channel (agent-loop turns `reason` into the
|
|
100
|
+
// tool result), so `handled` and `block` share the wire here — the guardian
|
|
101
|
+
// keeps them semantically distinct (no tool.blocked broadcast for handled).
|
|
102
|
+
if (decision && decision.handled) {
|
|
103
|
+
return { block: true, reason: decision.result ?? "" };
|
|
104
|
+
}
|
|
105
|
+
if (decision && decision.block) {
|
|
106
|
+
return { block: true, reason: decision.reason ?? `Blocked ${event.toolName}` };
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Binds a runtime-agnostic ToolProvider (Bivy integrations, MCP, …) into a Pi
|
|
113
|
+
* session by registering each ToolSpec as a Pi tool whose `execute` routes to
|
|
114
|
+
* `provider.invoke`. This is the ONLY Pi-specific knowledge about node-hosted
|
|
115
|
+
* tools; the provider itself — and everything upstream (server.ts, the RPC link,
|
|
116
|
+
* the agent service) — is agent-agnostic. The provider owns the implementation
|
|
117
|
+
* and credentials (locally in-process, or on the daemon via reverse RPC when the
|
|
118
|
+
* session is remote), so Pi is treated as just another agent that happens to know
|
|
119
|
+
* how to expose provided tools.
|
|
120
|
+
*/
|
|
121
|
+
function toolProviderFactory(provider) {
|
|
122
|
+
// Adapt a generic ToolResult to Pi's tool-result shape (content always present,
|
|
123
|
+
// text blocks). This mapping is the ONLY place the agent-agnostic ToolResult
|
|
124
|
+
// meets Pi's SDK types.
|
|
125
|
+
const toPiResult = (result) => ({
|
|
126
|
+
content: (result.content ?? []).map((c) => ({ type: "text", text: c.text ?? "" })),
|
|
127
|
+
details: (result.details ?? {}),
|
|
128
|
+
...(result.isError ? { isError: true } : {}),
|
|
129
|
+
});
|
|
130
|
+
return function providedTools(pi) {
|
|
131
|
+
for (const spec of provider.list()) {
|
|
132
|
+
pi.registerTool({
|
|
133
|
+
name: spec.name,
|
|
134
|
+
label: spec.label ?? spec.name,
|
|
135
|
+
description: spec.description ?? spec.name,
|
|
136
|
+
promptSnippet: spec.promptSnippet ?? spec.description ?? spec.name,
|
|
137
|
+
parameters: spec.parameters,
|
|
138
|
+
async execute(toolCallId, params, signal) {
|
|
139
|
+
try {
|
|
140
|
+
return toPiResult(await provider.invoke(spec.name, toolCallId, params, signal));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
return toPiResult({ content: [{ type: "text", text: `${spec.name} failed: ${message}` }], isError: true });
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
class PiSession {
|
|
152
|
+
runtime;
|
|
153
|
+
tui;
|
|
154
|
+
constructor(runtime, tui) {
|
|
155
|
+
this.runtime = runtime;
|
|
156
|
+
this.tui = tui;
|
|
157
|
+
}
|
|
158
|
+
get session() {
|
|
159
|
+
return this.runtime.session;
|
|
160
|
+
}
|
|
161
|
+
get id() {
|
|
162
|
+
return this.session.sessionManager.getSessionId();
|
|
163
|
+
}
|
|
164
|
+
get cwd() {
|
|
165
|
+
return this.runtime.cwd || this.session.sessionManager.getCwd();
|
|
166
|
+
}
|
|
167
|
+
get sessionFile() {
|
|
168
|
+
return this.session.sessionManager.getSessionFile();
|
|
169
|
+
}
|
|
170
|
+
get isStreaming() {
|
|
171
|
+
return this.session.isStreaming;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resume this exact session in pi's interactive TUI. Points the CLI at the
|
|
175
|
+
* daemon's own agent dir and session store (same files the SDK reads) and
|
|
176
|
+
* resumes by session file, so the TUI shows the live conversation. Returns
|
|
177
|
+
* null for an unsaved session (nothing to resume yet).
|
|
178
|
+
*/
|
|
179
|
+
async interactiveTuiCommand() {
|
|
180
|
+
const file = this.sessionFile;
|
|
181
|
+
if (!file)
|
|
182
|
+
return null;
|
|
183
|
+
// Pi's own TUI reads its plaintext auth.json store, so project the vault to
|
|
184
|
+
// disk (refreshed) for the hand-off. Best-effort: an empty auth.json just
|
|
185
|
+
// means the TUI prompts for login (which we ingest back on the next sync).
|
|
186
|
+
await provisionPiAuthJson(this.tui.credsDir, this.tui.piDir).catch(() => { });
|
|
187
|
+
return {
|
|
188
|
+
command: process.execPath,
|
|
189
|
+
args: [this.tui.piCli, "--session", file, "--session-dir", this.tui.sessionsDir],
|
|
190
|
+
env: { PI_CODING_AGENT_DIR: this.tui.piDir },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
prompt(text, options) {
|
|
194
|
+
return this.session.prompt(text, options);
|
|
195
|
+
}
|
|
196
|
+
abort() {
|
|
197
|
+
return this.session.abort();
|
|
198
|
+
}
|
|
199
|
+
dispose() {
|
|
200
|
+
void this.runtime.dispose();
|
|
201
|
+
}
|
|
202
|
+
getMessages() {
|
|
203
|
+
return this.session.state.messages;
|
|
204
|
+
}
|
|
205
|
+
subscribe(listener) {
|
|
206
|
+
return this.session.subscribe(listener);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pi's own slash commands for this session: extension commands (pi.register
|
|
210
|
+
* Command), prompt templates, and skills (/skill:name). The composer offers
|
|
211
|
+
* these in autocomplete; invoking one forwards "/name args" as a prompt, which
|
|
212
|
+
* Pi's prompt() executes via _tryExecuteExtensionCommand instead of sending to
|
|
213
|
+
* the model. Names are normalized to a leading "/" (Pi stores them without
|
|
214
|
+
* one). Best-effort: any SDK shape change degrades to an empty list, never a
|
|
215
|
+
* throw — this is display-only. Bivy's own control commands (/model, /new, …)
|
|
216
|
+
* win a name collision in the composer, so we don't filter them here.
|
|
217
|
+
*/
|
|
218
|
+
getCommands() {
|
|
219
|
+
return piSessionCommands(this.session);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The connected/available models for the picker.
|
|
223
|
+
*
|
|
224
|
+
* `ModelRuntime.getAvailable()` re-resolves provider auth against Bivy's
|
|
225
|
+
* credential store on each call, so a provider the user signs into *after* the
|
|
226
|
+
* session started (e.g. an OpenAI/ChatGPT OAuth login while a Claude session is
|
|
227
|
+
* open) surfaces here without a restart — no stale in-memory snapshot to
|
|
228
|
+
* reload. Async because that resolution reads the store.
|
|
229
|
+
*/
|
|
230
|
+
async getModels() {
|
|
231
|
+
const models = await this.session.modelRuntime.getAvailable();
|
|
232
|
+
return models.map((model) => toModelInfo(model, true));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Every model Pi knows about — built-in and custom, regardless of whether
|
|
236
|
+
* its provider currently has auth configured — so the model picker can
|
|
237
|
+
* offer unconnected providers with an inline "connect" action (#390). Each
|
|
238
|
+
* entry's `configured` flag comes straight from hasConfiguredAuth(), the
|
|
239
|
+
* same check setModel() already gates on.
|
|
240
|
+
*/
|
|
241
|
+
async getAllModels() {
|
|
242
|
+
const runtime = this.session.modelRuntime;
|
|
243
|
+
// Refresh availability first so `hasConfiguredAuth` reflects current creds.
|
|
244
|
+
await runtime.getAvailable();
|
|
245
|
+
return runtime.getModels().map((model) => toModelInfo(model, runtime.hasConfiguredAuth(model.provider)));
|
|
246
|
+
}
|
|
247
|
+
getCurrentModel() {
|
|
248
|
+
const model = this.session.model;
|
|
249
|
+
return model ? toModelInfo(model) : undefined;
|
|
250
|
+
}
|
|
251
|
+
async setModel(provider, id) {
|
|
252
|
+
const runtime = this.session.modelRuntime;
|
|
253
|
+
await runtime.getAvailable();
|
|
254
|
+
const model = runtime.getModel(provider, id);
|
|
255
|
+
if (!model || !runtime.hasConfiguredAuth(provider)) {
|
|
256
|
+
throw new Error("Model is not available on this node.");
|
|
257
|
+
}
|
|
258
|
+
await this.session.setModel(model);
|
|
259
|
+
}
|
|
260
|
+
getThinkingLevel() {
|
|
261
|
+
try {
|
|
262
|
+
return this.session.thinkingLevel;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
setThinkingLevel(level) {
|
|
269
|
+
try {
|
|
270
|
+
this.session.setThinkingLevel?.(level);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// ignore if runtime does not support
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
getAvailableThinkingLevels() {
|
|
277
|
+
try {
|
|
278
|
+
const fn = this.session.getAvailableThinkingLevels;
|
|
279
|
+
return typeof fn === "function" ? fn.call(this.session) : ["off", "minimal", "low", "medium", "high"];
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return ["off", "minimal", "low", "medium", "high"];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
supportsThinking() {
|
|
286
|
+
try {
|
|
287
|
+
const fn = this.session.supportsThinking;
|
|
288
|
+
if (typeof fn === "function")
|
|
289
|
+
return !!fn.call(this.session);
|
|
290
|
+
const cur = this.getCurrentModel();
|
|
291
|
+
return !!cur?.reasoning;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return !!this.getCurrentModel()?.reasoning;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
getName() {
|
|
298
|
+
return this.session.sessionName ?? this.session.sessionManager.getSessionName() ?? undefined;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Cost/token totals from Pi's own session stats. Pi has no concept of a
|
|
302
|
+
* metered OAuth/subscription plan quota (unlike Claude Code SDK), so `plan`
|
|
303
|
+
* is always left undefined here.
|
|
304
|
+
*/
|
|
305
|
+
async getUsage() {
|
|
306
|
+
try {
|
|
307
|
+
const stats = this.session.getSessionStats();
|
|
308
|
+
return {
|
|
309
|
+
costUsd: stats.cost,
|
|
310
|
+
tokens: {
|
|
311
|
+
input: stats.tokens.input,
|
|
312
|
+
output: stats.tokens.output,
|
|
313
|
+
cacheRead: stats.tokens.cacheRead,
|
|
314
|
+
cacheWrite: stats.tokens.cacheWrite,
|
|
315
|
+
total: stats.tokens.total,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
setName(name) {
|
|
324
|
+
this.session.setSessionName(name);
|
|
325
|
+
}
|
|
326
|
+
async suggestName(firstPrompt) {
|
|
327
|
+
const prompt = firstPrompt.trim();
|
|
328
|
+
if (!prompt)
|
|
329
|
+
return undefined;
|
|
330
|
+
const model = this.session.model;
|
|
331
|
+
if (!model)
|
|
332
|
+
return undefined;
|
|
333
|
+
const auth = await this.session.modelRuntime.getAuth(model);
|
|
334
|
+
if (!auth)
|
|
335
|
+
return undefined;
|
|
336
|
+
const response = await completeSimple(model, {
|
|
337
|
+
systemPrompt: "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.",
|
|
338
|
+
messages: [
|
|
339
|
+
{ role: "user", content: `Create a short title for this coding-agent session using the full first message below:\n\n${prompt.slice(0, 4000)}`, timestamp: Date.now() },
|
|
340
|
+
],
|
|
341
|
+
}, { apiKey: auth.auth.apiKey, headers: auth.auth.headers, env: auth.env, temperature: 0.2, maxTokens: 24, reasoning: "minimal", sessionId: `${this.id}:name` });
|
|
342
|
+
const text = response.content
|
|
343
|
+
.filter((part) => part.type === "text")
|
|
344
|
+
.map((part) => part.text)
|
|
345
|
+
.join(" ");
|
|
346
|
+
const name = cleanSessionName(text);
|
|
347
|
+
return name || undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export class PiRuntime {
|
|
351
|
+
options;
|
|
352
|
+
id = "pi";
|
|
353
|
+
displayName = "Pi";
|
|
354
|
+
capabilities = {
|
|
355
|
+
toolInterception: true,
|
|
356
|
+
modelSelection: true,
|
|
357
|
+
packages: true,
|
|
358
|
+
resume: true,
|
|
359
|
+
fork: false,
|
|
360
|
+
// pi resumes by session-file path (under the node's sessions dir), so the
|
|
361
|
+
// daemon applies its path-traversal guard to these refs.
|
|
362
|
+
sessionRefIsPath: true,
|
|
363
|
+
// pi's TUI ships with the node, so the chat<->TUI hand-off is always offered.
|
|
364
|
+
interactiveTui: true,
|
|
365
|
+
usageReporting: true,
|
|
366
|
+
// pi transcripts are structured messages that round-trip through the session
|
|
367
|
+
// store, so a pi->pi fork is full fidelity (see exportForFork/importForFork).
|
|
368
|
+
forkTransport: true,
|
|
369
|
+
// The pi-coding-agent SDK implements both explicitly: prompting mid-turn
|
|
370
|
+
// with no streamingBehavior hint throws, forcing every caller to choose.
|
|
371
|
+
streamingBehaviors: ["steer", "followUp"],
|
|
372
|
+
};
|
|
373
|
+
constructor(options) {
|
|
374
|
+
this.options = options;
|
|
375
|
+
}
|
|
376
|
+
async build(sessionManager, options) {
|
|
377
|
+
const { credsDir, piDir } = this.options;
|
|
378
|
+
// Pi is just another agent here: it reads through the credential store Bivy
|
|
379
|
+
// owns, injected as the ModelRuntime's CredentialStore. Network is allowed so
|
|
380
|
+
// dynamic model lists load for the session.
|
|
381
|
+
const modelRuntime = await createPiModelRuntime({ credsDir, piDir, allowModelNetwork: true });
|
|
382
|
+
const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
383
|
+
const sessionId = sessionManager.getSessionId();
|
|
384
|
+
const providedTools = options.toolProvider ? [toolProviderFactory(options.toolProvider)] : [];
|
|
385
|
+
const services = await createAgentSessionServices({
|
|
386
|
+
cwd,
|
|
387
|
+
agentDir,
|
|
388
|
+
modelRuntime,
|
|
389
|
+
resourceLoaderOptions: {
|
|
390
|
+
extensionFactories: [guardianFactory(sessionId, options.toolInterceptor), ...providedTools],
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
|
|
394
|
+
return { ...result, services, diagnostics: services.diagnostics };
|
|
395
|
+
};
|
|
396
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
397
|
+
cwd: sessionManager.getCwd() || options.workspace,
|
|
398
|
+
agentDir: piDir,
|
|
399
|
+
sessionManager,
|
|
400
|
+
});
|
|
401
|
+
const tui = { credsDir, piDir, sessionsDir: this.options.sessionsDir, piCli: resolvePiCli() };
|
|
402
|
+
return { session: new PiSession(runtime, tui), warning: runtime.modelFallbackMessage };
|
|
403
|
+
}
|
|
404
|
+
createSession(options) {
|
|
405
|
+
const sessionManager = SessionManager.create(options.workspace, this.options.sessionsDir);
|
|
406
|
+
return this.build(sessionManager, options);
|
|
407
|
+
}
|
|
408
|
+
openSession(options) {
|
|
409
|
+
const sessionManager = SessionManager.open(options.sessionFile, this.options.sessionsDir);
|
|
410
|
+
return this.build(sessionManager, options);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Delete a session's transcript file on a user-initiated delete. pi keeps no
|
|
414
|
+
* in-memory session registry — listSessions reads the store on disk each time —
|
|
415
|
+
* so the whole job is removing the file so SessionManager.listAll stops
|
|
416
|
+
* returning it. Prefer the exact path listAll reports for this id; fall back to
|
|
417
|
+
* a caller-supplied file path (pi's resume ref is itself a path). The node's
|
|
418
|
+
* own inRoot unlink already covers the open-session case; this closes the gap
|
|
419
|
+
* for a session deleted by id alone (e.g. after a restart, when the node holds
|
|
420
|
+
* no live record and never learned the path). Returns true if a file was removed.
|
|
421
|
+
*/
|
|
422
|
+
async deleteSession(sessionId, sessionFile) {
|
|
423
|
+
let file;
|
|
424
|
+
try {
|
|
425
|
+
const match = (await SessionManager.listAll(this.options.sessionsDir)).find((s) => s.id === sessionId);
|
|
426
|
+
file = match?.path;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Store unreadable — fall back to the caller-supplied path below.
|
|
430
|
+
}
|
|
431
|
+
if (!file && sessionFile && sessionFile !== sessionId)
|
|
432
|
+
file = sessionFile;
|
|
433
|
+
if (!file)
|
|
434
|
+
return false;
|
|
435
|
+
try {
|
|
436
|
+
await fs.promises.unlink(file);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error?.code === "ENOENT")
|
|
441
|
+
return false;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Pi's provider/model catalog (session-less) — its contribution to the unified picker. */
|
|
446
|
+
async listCatalog() {
|
|
447
|
+
const runtime = await createPiModelRuntime({ credsDir: this.options.credsDir, piDir: this.options.piDir });
|
|
448
|
+
return runtime.getProviders().map((provider) => ({
|
|
449
|
+
id: provider.id,
|
|
450
|
+
name: provider.name,
|
|
451
|
+
oauth: isNativeOAuthProvider(provider.id),
|
|
452
|
+
models: runtime.getModels(provider.id).map((model) => toModelInfo(model, true)),
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
async listSessions() {
|
|
456
|
+
const sessions = await SessionManager.listAll(this.options.sessionsDir);
|
|
457
|
+
return sessions.map((s) => ({
|
|
458
|
+
id: s.id,
|
|
459
|
+
path: s.path,
|
|
460
|
+
cwd: s.cwd,
|
|
461
|
+
name: s.name,
|
|
462
|
+
created: s.created,
|
|
463
|
+
modified: s.modified,
|
|
464
|
+
messageCount: s.messageCount,
|
|
465
|
+
firstMessage: s.firstMessage,
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Read a session's transcript from its file without spinning up the runtime.
|
|
470
|
+
* `SessionManager.open` just parses+indexes the JSONL; `buildSessionContext`
|
|
471
|
+
* resolves the message list the same way a live resume does — sdk.js sets
|
|
472
|
+
* `agent.state.messages = buildSessionContext().messages` on resume, so this is
|
|
473
|
+
* the exact shape PiSession.getMessages() would later return. Best-effort: a
|
|
474
|
+
* missing/corrupt file reads as an empty transcript (or undefined if the read
|
|
475
|
+
* itself throws), which the caller treats as "nothing to fast-paint".
|
|
476
|
+
*/
|
|
477
|
+
readMessages(sessionFile) {
|
|
478
|
+
try {
|
|
479
|
+
const sessionManager = SessionManager.open(sessionFile, this.options.sessionsDir);
|
|
480
|
+
return sessionManager.buildSessionContext().messages;
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Export a pi session for a same-runtime fork. The payload is just the
|
|
488
|
+
* structured message list a resume would load (`readMessages`) — pi messages
|
|
489
|
+
* round-trip through the session store, so re-appending them on the
|
|
490
|
+
* destination reproduces the conversation exactly. Undefined when the source
|
|
491
|
+
* transcript can't be read.
|
|
492
|
+
*/
|
|
493
|
+
exportForFork(sessionFile) {
|
|
494
|
+
const messages = this.readMessages(sessionFile);
|
|
495
|
+
if (!messages)
|
|
496
|
+
return undefined;
|
|
497
|
+
return { runtimeId: this.id, kind: "pi-messages", data: { messages } };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Recreate a pi session from an exported payload: open a fresh session in the
|
|
501
|
+
* destination workspace and replay each message into its store, so the new
|
|
502
|
+
* session resumes with the full transcript. Never touches the source.
|
|
503
|
+
*/
|
|
504
|
+
async importForFork(payload, ctx) {
|
|
505
|
+
if (payload.runtimeId !== this.id || payload.kind !== "pi-messages") {
|
|
506
|
+
throw new Error(`pi.importForFork: unexpected payload ${payload.runtimeId}/${payload.kind}`);
|
|
507
|
+
}
|
|
508
|
+
const messages = (payload.data?.messages ?? []);
|
|
509
|
+
const sessionManager = SessionManager.create(ctx.cwd || ctx.workspace, this.options.sessionsDir);
|
|
510
|
+
for (const message of messages) {
|
|
511
|
+
sessionManager.appendMessage(message);
|
|
512
|
+
}
|
|
513
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
514
|
+
if (!sessionFile)
|
|
515
|
+
throw new Error("pi.importForFork: session file was not persisted");
|
|
516
|
+
return { sessionFile, id: sessionManager.getSessionId() };
|
|
517
|
+
}
|
|
518
|
+
}
|