@bivy/bivy 0.0.0 → 0.1.0-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,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
+ }