@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,194 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Bivy's app-owned local/custom model registry — the source of truth for
|
|
4
|
+
// user-provided model endpoints (Ollama, LM Studio, vLLM, SGLang, and any
|
|
5
|
+
// OpenAI-compatible / self-hosted inference server).
|
|
6
|
+
//
|
|
7
|
+
// This module is deliberately PI-FREE (no import from any @earendil-works
|
|
8
|
+
// package, not even a type), mirroring credential-store.ts. Bivy owns the
|
|
9
|
+
// storage and the registry shape; Pi consumes it as just another agent via a
|
|
10
|
+
// derived projection into its own `models.json` (see server.ts writePiModelsProjection).
|
|
11
|
+
//
|
|
12
|
+
// The registry holds NON-SECRET configuration ONLY: base URLs, API shape, and
|
|
13
|
+
// model metadata (context window, cost, reasoning capability). It never stores
|
|
14
|
+
// API keys — those live in Bivy's encrypted credential vault
|
|
15
|
+
// (credential-store.ts), keyed by the same provider id, and are stitched back
|
|
16
|
+
// in only when projecting Pi's models.json. The registry rides the existing
|
|
17
|
+
// encrypted control-plane push so custom endpoints follow the account across
|
|
18
|
+
// nodes and devices; the keys ride the credential half of that same push.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
const FILE_NAME = "local-models.json";
|
|
22
|
+
const DEFAULT_BASE_URL = "http://localhost:11434/v1";
|
|
23
|
+
const DEFAULT_API = "openai-completions";
|
|
24
|
+
function configPath(dir) {
|
|
25
|
+
return path.join(dir, FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
/** Normalize a provider id the way every path expects (slug-safe). */
|
|
28
|
+
export function normalizeProviderId(id) {
|
|
29
|
+
return (String(id ?? "")
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9-]+/g, "-") // non-slug runs collapse to a single dash
|
|
33
|
+
.replace(/^-+|-+$/g, "") // no leading/trailing dashes
|
|
34
|
+
|| "local");
|
|
35
|
+
}
|
|
36
|
+
function normalizeModel(raw) {
|
|
37
|
+
const id = String(raw?.id ?? "").trim();
|
|
38
|
+
if (!id)
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name: raw?.name ? String(raw.name) : undefined,
|
|
43
|
+
reasoning: !!raw?.reasoning,
|
|
44
|
+
input: Array.isArray(raw?.input) && raw.input.length ? raw.input.map((x) => String(x)) : ["text"],
|
|
45
|
+
contextWindow: Number(raw?.contextWindow) || 128000,
|
|
46
|
+
maxTokens: Number(raw?.maxTokens) || 16384,
|
|
47
|
+
cost: raw?.cost && typeof raw.cost === "object"
|
|
48
|
+
? {
|
|
49
|
+
input: Number(raw.cost.input) || 0,
|
|
50
|
+
output: Number(raw.cost.output) || 0,
|
|
51
|
+
cacheRead: Number(raw.cost.cacheRead) || 0,
|
|
52
|
+
cacheWrite: Number(raw.cost.cacheWrite) || 0,
|
|
53
|
+
}
|
|
54
|
+
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Coerce arbitrary input into a normalized provider (drops empty models). */
|
|
58
|
+
export function normalizeProvider(id, raw) {
|
|
59
|
+
const models = Array.isArray(raw?.models)
|
|
60
|
+
? raw.models.map(normalizeModel).filter((m) => !!m)
|
|
61
|
+
: [];
|
|
62
|
+
const provider = {
|
|
63
|
+
id: normalizeProviderId(id),
|
|
64
|
+
baseUrl: String(raw?.baseUrl ?? "").trim() || DEFAULT_BASE_URL,
|
|
65
|
+
api: String(raw?.api ?? "").trim() || DEFAULT_API,
|
|
66
|
+
models,
|
|
67
|
+
};
|
|
68
|
+
if (raw?.name)
|
|
69
|
+
provider.name = String(raw.name);
|
|
70
|
+
if (raw?.compat && typeof raw.compat === "object")
|
|
71
|
+
provider.compat = raw.compat;
|
|
72
|
+
// Any incoming `apiKey` is intentionally dropped — keys live in the vault.
|
|
73
|
+
return provider;
|
|
74
|
+
}
|
|
75
|
+
export function loadLocalModels(dir) {
|
|
76
|
+
const file = configPath(dir);
|
|
77
|
+
if (!fs.existsSync(file))
|
|
78
|
+
return { providers: {} };
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
81
|
+
if (!parsed || typeof parsed !== "object")
|
|
82
|
+
return { providers: {} };
|
|
83
|
+
const providersRaw = parsed.providers;
|
|
84
|
+
if (!providersRaw || typeof providersRaw !== "object")
|
|
85
|
+
return { providers: {} };
|
|
86
|
+
const providers = {};
|
|
87
|
+
for (const [id, spec] of Object.entries(providersRaw)) {
|
|
88
|
+
const norm = normalizeProvider(id, spec);
|
|
89
|
+
providers[norm.id] = norm;
|
|
90
|
+
}
|
|
91
|
+
return { providers };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { providers: {} };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function saveLocalModels(dir, cfg) {
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
const safe = { providers: (cfg && cfg.providers) || {} };
|
|
100
|
+
const file = configPath(dir);
|
|
101
|
+
fs.writeFileSync(file, `${JSON.stringify(safe, null, 2)}\n`, { mode: 0o600 });
|
|
102
|
+
try {
|
|
103
|
+
fs.chmodSync(file, 0o600);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* best effort */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Insert or update one provider; returns the normalized id and full config. */
|
|
110
|
+
export function upsertLocalProvider(dir, id, spec) {
|
|
111
|
+
const cfg = loadLocalModels(dir);
|
|
112
|
+
const pid = normalizeProviderId(id);
|
|
113
|
+
const prev = cfg.providers[pid];
|
|
114
|
+
// Merge onto any existing entry so a partial save (e.g. just a new model)
|
|
115
|
+
// doesn't clobber the base URL. Explicit fields in `spec` win.
|
|
116
|
+
const merged = normalizeProvider(pid, { ...(prev ?? {}), ...(spec ?? {}) });
|
|
117
|
+
cfg.providers[pid] = merged;
|
|
118
|
+
saveLocalModels(dir, cfg);
|
|
119
|
+
return { id: pid, config: cfg };
|
|
120
|
+
}
|
|
121
|
+
export function removeLocalProviderEntry(dir, id) {
|
|
122
|
+
const cfg = loadLocalModels(dir);
|
|
123
|
+
delete cfg.providers[normalizeProviderId(id)];
|
|
124
|
+
saveLocalModels(dir, cfg);
|
|
125
|
+
return cfg;
|
|
126
|
+
}
|
|
127
|
+
/** Redacted list for the UI. `hasKey(id)` reports vault key presence (the store
|
|
128
|
+
* itself holds no keys), defaulting to false when no resolver is supplied. */
|
|
129
|
+
export function listLocalProviderSummaries(dir, hasKey = () => false) {
|
|
130
|
+
const cfg = loadLocalModels(dir);
|
|
131
|
+
return Object.values(cfg.providers)
|
|
132
|
+
.map((p) => ({
|
|
133
|
+
id: p.id,
|
|
134
|
+
name: p.name,
|
|
135
|
+
baseUrl: p.baseUrl,
|
|
136
|
+
api: p.api,
|
|
137
|
+
hasKey: hasKey(p.id),
|
|
138
|
+
modelCount: p.models.length,
|
|
139
|
+
models: p.models.map((m) => ({ id: m.id, name: m.name || m.id })),
|
|
140
|
+
}))
|
|
141
|
+
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
|
142
|
+
}
|
|
143
|
+
/** Raw registry map for the control-plane sync payload. */
|
|
144
|
+
export function exportLocalModels(dir) {
|
|
145
|
+
return loadLocalModels(dir).providers;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Merge a synced snapshot into the local registry. Non-destructive per the same
|
|
149
|
+
* philosophy as credential import: incoming providers are applied on top; the
|
|
150
|
+
* caller decides removals by re-pushing a snapshot without them (see server
|
|
151
|
+
* sync). Returns true if anything actually changed.
|
|
152
|
+
*/
|
|
153
|
+
export function importLocalModels(dir, incoming) {
|
|
154
|
+
if (!incoming || typeof incoming !== "object")
|
|
155
|
+
return false;
|
|
156
|
+
const cfg = loadLocalModels(dir);
|
|
157
|
+
let changed = false;
|
|
158
|
+
for (const [id, spec] of Object.entries(incoming)) {
|
|
159
|
+
if (!spec || typeof spec !== "object")
|
|
160
|
+
continue;
|
|
161
|
+
const norm = normalizeProvider(id, spec);
|
|
162
|
+
if (JSON.stringify(cfg.providers[norm.id]) !== JSON.stringify(norm)) {
|
|
163
|
+
cfg.providers[norm.id] = norm;
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (changed)
|
|
168
|
+
saveLocalModels(dir, cfg);
|
|
169
|
+
return changed;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Project the Bivy registry into the shape Pi's ModelRuntime reads from
|
|
173
|
+
* `models.json`. Pi only ever consumed custom/local providers from this file,
|
|
174
|
+
* so a full regeneration from Bivy's registry is faithful and keeps Pi a pure
|
|
175
|
+
* downstream consumer (never the source of truth).
|
|
176
|
+
*/
|
|
177
|
+
export function toPiModelsConfig(cfg, resolveKey = () => undefined) {
|
|
178
|
+
const providers = {};
|
|
179
|
+
for (const [id, p] of Object.entries(cfg.providers)) {
|
|
180
|
+
providers[id] = {
|
|
181
|
+
name: p.name,
|
|
182
|
+
baseUrl: p.baseUrl,
|
|
183
|
+
api: p.api,
|
|
184
|
+
// Key comes from the vault; local servers ignore it, so "local" is a safe
|
|
185
|
+
// default when none is stored (Ollama, LM Studio, vLLM, …).
|
|
186
|
+
apiKey: resolveKey(id) || "local",
|
|
187
|
+
...(p.compat ? { compat: p.compat } : {}),
|
|
188
|
+
// Stamp each model with the provider's api so Pi dispatches to the right
|
|
189
|
+
// implementation (e.g. azure-openai-responses) even when a model omits it.
|
|
190
|
+
models: p.models.map((m) => ({ api: p.api, ...m })),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { providers };
|
|
194
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Node-local registry: each daemon knows only its own entries. This is exactly
|
|
5
|
+
* today's behavior (no cross-daemon sharing) and the safe default while the flag
|
|
6
|
+
* is off. A shared implementation replaces it to unlock statelessness — same
|
|
7
|
+
* interface, so no call site changes. Values are shallow-copied in and out so a
|
|
8
|
+
* caller's later mutation can't alias a stored entry.
|
|
9
|
+
*/
|
|
10
|
+
export class InMemoryLocationRegistry {
|
|
11
|
+
byKey = new Map();
|
|
12
|
+
async record(key, value) {
|
|
13
|
+
if (!key)
|
|
14
|
+
throw new Error("location key is required");
|
|
15
|
+
this.byKey.set(key, { ...value });
|
|
16
|
+
}
|
|
17
|
+
async lookup(key) {
|
|
18
|
+
const found = this.byKey.get(key);
|
|
19
|
+
return found ? { ...found } : undefined;
|
|
20
|
+
}
|
|
21
|
+
async forget(key) {
|
|
22
|
+
this.byKey.delete(key);
|
|
23
|
+
}
|
|
24
|
+
/** Number of recorded entries (test/introspection aid). */
|
|
25
|
+
get size() {
|
|
26
|
+
return this.byKey.size;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Unified model catalog — aggregated across agents, not sourced from pi alone.
|
|
4
|
+
//
|
|
5
|
+
// Each agent runtime optionally exposes a session-less `listCatalog()` (its
|
|
6
|
+
// providers + models). Bivy unions them here into one catalog, deduped by
|
|
7
|
+
// provider/model, and stamps each provider with auth state from Bivy's own vault
|
|
8
|
+
// — so the "Models & providers" surface reflects what *any* installed agent can
|
|
9
|
+
// run, with pi as just one (rich) contributor. New api-key providers are
|
|
10
|
+
// supported dynamically (the provider→env mapping is generic); OAuth providers
|
|
11
|
+
// are those in Bivy's native registry.
|
|
12
|
+
import { createCredentialVault } from "./credential-store.js";
|
|
13
|
+
import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
|
|
14
|
+
async function catalogOf(runtime) {
|
|
15
|
+
if (!runtime.listCatalog)
|
|
16
|
+
return [];
|
|
17
|
+
try {
|
|
18
|
+
return await runtime.listCatalog();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// A runtime that can't enumerate its catalog (CLI missing, offline) simply
|
|
22
|
+
// contributes nothing rather than breaking the aggregate.
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Aggregate the model catalog across the given agent runtimes and stamp Bivy's
|
|
28
|
+
* vault auth state onto each provider. Runtimes are queried concurrently; a
|
|
29
|
+
* failing one is skipped.
|
|
30
|
+
*/
|
|
31
|
+
export async function aggregateModelCatalog(runtimes, credsDir) {
|
|
32
|
+
const [perAgent, stored] = await Promise.all([
|
|
33
|
+
Promise.all(runtimes.map(async (runtime) => ({ agent: runtime.id, providers: await catalogOf(runtime) }))),
|
|
34
|
+
createCredentialVault(credsDir).list().catch(() => []),
|
|
35
|
+
]);
|
|
36
|
+
const configured = new Set(stored.map((info) => info.providerId));
|
|
37
|
+
const byProvider = new Map();
|
|
38
|
+
const seenModel = new Map();
|
|
39
|
+
for (const { agent, providers } of perAgent) {
|
|
40
|
+
for (const provider of providers) {
|
|
41
|
+
const id = provider.id.trim().toLowerCase();
|
|
42
|
+
if (!id)
|
|
43
|
+
continue;
|
|
44
|
+
let entry = byProvider.get(id);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
entry = { id, name: provider.name || id, oauth: isNativeOAuthProvider(id), configured: configured.has(id), agents: [], models: [] };
|
|
47
|
+
byProvider.set(id, entry);
|
|
48
|
+
seenModel.set(id, new Set());
|
|
49
|
+
}
|
|
50
|
+
if (provider.oauth)
|
|
51
|
+
entry.oauth = true;
|
|
52
|
+
if (!entry.agents.includes(agent))
|
|
53
|
+
entry.agents.push(agent);
|
|
54
|
+
const models = seenModel.get(id);
|
|
55
|
+
for (const model of provider.models) {
|
|
56
|
+
if (models.has(model.id))
|
|
57
|
+
continue;
|
|
58
|
+
models.add(model.id);
|
|
59
|
+
entry.models.push(model);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...byProvider.values()]
|
|
64
|
+
.map((provider) => ({ ...provider, models: provider.models.sort((a, b) => a.name.localeCompare(b.name)) }))
|
|
65
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Merge an authoritative per-provider auth status (from the base agent, which
|
|
69
|
+
* knows env vs stored) with the cross-agent catalog. Base rows keep their status
|
|
70
|
+
* and gain `agents`/`models`; providers only other agents know about are appended
|
|
71
|
+
* with the catalog's vault-derived status. Pure — the whole reason it's testable.
|
|
72
|
+
*/
|
|
73
|
+
export function mergeProviderCatalog(base, catalog) {
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
for (const row of base)
|
|
76
|
+
byId.set(row.id, { ...row, agents: [], models: [] });
|
|
77
|
+
for (const provider of catalog) {
|
|
78
|
+
const existing = byId.get(provider.id);
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.agents = provider.agents;
|
|
81
|
+
existing.models = provider.models;
|
|
82
|
+
if (provider.oauth)
|
|
83
|
+
existing.oauth = true;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
byId.set(provider.id, {
|
|
87
|
+
id: provider.id,
|
|
88
|
+
name: provider.name,
|
|
89
|
+
oauth: provider.oauth,
|
|
90
|
+
configured: provider.configured,
|
|
91
|
+
agents: provider.agents,
|
|
92
|
+
models: provider.models,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
97
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Node-level fallback session namer.
|
|
4
|
+
//
|
|
5
|
+
// A runtime's own adapter names a session with its own model (PiSession.suggestName,
|
|
6
|
+
// ClaudeSession.suggestName). The generic CLI agents (codex, opencode, aider, …) run
|
|
7
|
+
// the "dumb-pipe" ProcessRuntime, which has no model — so as a node-level fallback
|
|
8
|
+
// Bivy resolves the CLI's selected provider/model against Pi's model registry (Bivy
|
|
9
|
+
// has no model catalog of its own; Pi's registry is the borrowed model metadata) and
|
|
10
|
+
// asks it for a title using whatever credential the shared vault holds.
|
|
11
|
+
//
|
|
12
|
+
// This is deliberately the ONLY place outside the Pi adapter that touches the Pi
|
|
13
|
+
// inference SDK, kept under src/runtime/ so the core daemon (src/server.ts) stays
|
|
14
|
+
// free of any @earendil-works import.
|
|
15
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
16
|
+
import { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createPiModelRuntime } from "./pi-oauth.js";
|
|
18
|
+
function cleanSessionName(value) {
|
|
19
|
+
return value
|
|
20
|
+
.replace(/[\r\n"'`]/g, " ")
|
|
21
|
+
.replace(/\p{Control}/gu, "")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/[.?!,:;\-–—]+$/g, "")
|
|
25
|
+
.slice(0, 60)
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A CLI can report its model in provider-qualified (`anthropic/claude-…`) or bare
|
|
30
|
+
* form; try the given provider/id first, then a `provider/id` split, so the
|
|
31
|
+
* registry lookup succeeds for either convention.
|
|
32
|
+
*/
|
|
33
|
+
function selectedModelCandidates(provider, id) {
|
|
34
|
+
const cleanProvider = provider?.trim();
|
|
35
|
+
const cleanId = id?.trim();
|
|
36
|
+
if (!cleanProvider || !cleanId)
|
|
37
|
+
return [];
|
|
38
|
+
const out = [{ provider: cleanProvider, id: cleanId }];
|
|
39
|
+
const slash = cleanId.indexOf("/");
|
|
40
|
+
if (slash > 0) {
|
|
41
|
+
const prefixedProvider = cleanId.slice(0, slash);
|
|
42
|
+
const bareId = cleanId.slice(slash + 1);
|
|
43
|
+
if (prefixedProvider && bareId)
|
|
44
|
+
out.push({ provider: prefixedProvider, id: bareId });
|
|
45
|
+
}
|
|
46
|
+
return out.filter((candidate, index) => out.findIndex((x) => x.provider === candidate.provider && x.id === candidate.id) === index);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Title a session with the provider/model the user selected for it, resolved via
|
|
50
|
+
* Pi's model registry against the shared vault's credentials. Returns undefined
|
|
51
|
+
* when the model can't be resolved, no credential is configured, or the request
|
|
52
|
+
* fails — the caller keeps its deterministic title / node-level fallback.
|
|
53
|
+
*/
|
|
54
|
+
export async function suggestNameFromSelectedModel(opts) {
|
|
55
|
+
const prompt = opts.firstPrompt.trim();
|
|
56
|
+
if (!prompt)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const registry = new ModelRegistry(await createPiModelRuntime({ credsDir: opts.credsDir, piDir: opts.piDir }));
|
|
60
|
+
let model;
|
|
61
|
+
for (const candidate of selectedModelCandidates(opts.provider, opts.id)) {
|
|
62
|
+
model = registry.find(candidate.provider, candidate.id);
|
|
63
|
+
if (model)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (!model)
|
|
67
|
+
return undefined;
|
|
68
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
69
|
+
if (!auth.ok)
|
|
70
|
+
return undefined;
|
|
71
|
+
const response = await completeSimple(model, {
|
|
72
|
+
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.",
|
|
73
|
+
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)}`, timestamp: Date.now() }],
|
|
74
|
+
}, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, temperature: 0.2, maxTokens: 24, reasoning: "minimal", sessionId: `${opts.sessionId}:name` });
|
|
75
|
+
const text = response.content
|
|
76
|
+
.filter((part) => part.type === "text")
|
|
77
|
+
.map((part) => part.text)
|
|
78
|
+
.join(" ");
|
|
79
|
+
return cleanSessionName(text) || undefined;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.warn("Selected-model session naming failed", error);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Best-effort "is there a live process for this discovered session" check (see
|
|
4
|
+
// issue #156's "detect live external processes and offer follow/read-only or
|
|
5
|
+
// safe takeover"). Bivy has no attach/IPC channel into a bare `claude`/`codex`
|
|
6
|
+
// a user launched outside it, so this can only ever be a heuristic: match a
|
|
7
|
+
// running process whose command name is one of the provider's binaries AND
|
|
8
|
+
// whose current working directory equals the discovered session's cwd. A false
|
|
9
|
+
// negative (missed live process) just means adoption proceeds as if idle; a
|
|
10
|
+
// false positive only costs an extra confirmation. Never used for anything
|
|
11
|
+
// security-sensitive — display/gating only.
|
|
12
|
+
//
|
|
13
|
+
// The process listing is injectable so callers (and tests) never depend on the
|
|
14
|
+
// real OS: `hasLiveProcessForCwd`'s default lister shells out to `ps`, which is
|
|
15
|
+
// unavailable/unreliable on some platforms and always best-effort (wrapped in
|
|
16
|
+
// try/catch, empty on any failure).
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
/**
|
|
21
|
+
* List processes currently running on this node. Linux: reads `/proc` for an
|
|
22
|
+
* exact cwd via `/proc/<pid>/cwd`. Other platforms: falls back to `ps -axo
|
|
23
|
+
* pid=,comm=` with no cwd (so cwd-based matching below always reports "not
|
|
24
|
+
* live" there rather than guessing). Never throws; returns [] on any failure.
|
|
25
|
+
*/
|
|
26
|
+
export function listOsProcesses() {
|
|
27
|
+
try {
|
|
28
|
+
if (process.platform === "linux")
|
|
29
|
+
return listLinuxProcesses();
|
|
30
|
+
return listProcessesViaPs();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function listLinuxProcesses() {
|
|
37
|
+
const out = [];
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = fs.readdirSync("/proc");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!/^\d+$/.test(entry))
|
|
47
|
+
continue;
|
|
48
|
+
const pid = Number(entry);
|
|
49
|
+
let comm;
|
|
50
|
+
try {
|
|
51
|
+
comm = fs.readFileSync(`/proc/${entry}/comm`, "utf8").trim();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue; // process exited mid-scan, or unreadable — skip it
|
|
55
|
+
}
|
|
56
|
+
let cwd;
|
|
57
|
+
try {
|
|
58
|
+
cwd = fs.readlinkSync(`/proc/${entry}/cwd`);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
cwd = undefined; // permission denied (another user's process) — command-only match
|
|
62
|
+
}
|
|
63
|
+
out.push({ pid, command: comm, cwd });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** macOS/other POSIX fallback: `ps` reports the command name only, no cwd. */
|
|
68
|
+
function listProcessesViaPs() {
|
|
69
|
+
const result = spawnSync("ps", ["-axo", "pid=,comm="], { encoding: "utf8" });
|
|
70
|
+
if (result.status !== 0 || !result.stdout)
|
|
71
|
+
return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed)
|
|
76
|
+
continue;
|
|
77
|
+
const match = /^(\d+)\s+(.+)$/.exec(trimmed);
|
|
78
|
+
if (!match)
|
|
79
|
+
continue;
|
|
80
|
+
const command = match[2].split(/[\\/]/).pop() ?? match[2];
|
|
81
|
+
out.push({ pid: Number(match[1]), command });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Best-effort: is one of `binNames` currently running with cwd === `cwd`?
|
|
87
|
+
* Returns false (never throws) when the cwd can't be determined for any
|
|
88
|
+
* candidate process on this platform — an unproven match is treated as "not
|
|
89
|
+
* live" rather than risking a false "active" that would block adoption.
|
|
90
|
+
*/
|
|
91
|
+
export function hasLiveProcessForCwd(cwd, binNames, lister = listOsProcesses) {
|
|
92
|
+
if (!cwd)
|
|
93
|
+
return false;
|
|
94
|
+
const target = path.resolve(cwd);
|
|
95
|
+
const names = new Set(binNames.map((n) => n.toLowerCase()));
|
|
96
|
+
try {
|
|
97
|
+
return lister().some((p) => p.cwd != null && names.has(p.command.toLowerCase()) && path.resolve(p.cwd) === target);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Capability-driven discovery/adoption of provider-native sessions (issue #156).
|
|
4
|
+
//
|
|
5
|
+
// This module is deliberately runtime-agnostic and side-effect-free: it never
|
|
6
|
+
// touches a filesystem or spawns a process itself — that stays inside each
|
|
7
|
+
// runtime's own `discoverNativeSessions()` (src/runtime/codex-sessions.ts,
|
|
8
|
+
// src/runtime/claude-code.ts). What lives here is the shared policy every
|
|
9
|
+
// runtime shares: aggregate discoveries across capable runtimes, drop ones
|
|
10
|
+
// Bivy already manages, and decide how an adoption should proceed. Pure logic
|
|
11
|
+
// is easy to test with fake runtimes and fixtures, which is exactly what issue
|
|
12
|
+
// #156 asks for ("tests use provider fixtures for path discovery, dedupe,
|
|
13
|
+
// resume, non-default homes, and unsupported runtimes").
|
|
14
|
+
import { sessionIdentityKey } from "../session-identity.js";
|
|
15
|
+
/** Build the set of identity keys for every session Bivy already manages, in
|
|
16
|
+
* the same `ref:<path>` / `id:<id>` scheme session-identity.ts uses — so a
|
|
17
|
+
* discovered session that resolves to the same durable conversation (whether
|
|
18
|
+
* by on-disk path or by provider session id) is recognized as already-managed
|
|
19
|
+
* regardless of which runtime variant (e.g. `codex` vs `codex-approvals`)
|
|
20
|
+
* originally opened it. */
|
|
21
|
+
function managedIdentityKeys(managed) {
|
|
22
|
+
const keys = new Set();
|
|
23
|
+
for (const session of managed) {
|
|
24
|
+
keys.add(sessionIdentityKey({ id: session.id }));
|
|
25
|
+
if (session.path)
|
|
26
|
+
keys.add(sessionIdentityKey({ path: session.path }));
|
|
27
|
+
}
|
|
28
|
+
return keys;
|
|
29
|
+
}
|
|
30
|
+
/** True when `session` resolves to a conversation Bivy already manages —
|
|
31
|
+
* either by its provider ref (an id-based runtime's resume token) or by its
|
|
32
|
+
* on-disk transcript path, when known. */
|
|
33
|
+
export function isAlreadyManaged(session, managedKeys) {
|
|
34
|
+
if (managedKeys.has(sessionIdentityKey({ id: session.ref })))
|
|
35
|
+
return true;
|
|
36
|
+
if (session.file && managedKeys.has(sessionIdentityKey({ path: session.file })))
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Collect discoverable sessions across every capable runtime, deduped against
|
|
42
|
+
* Bivy-managed sessions AND against each other (two runtime variants over the
|
|
43
|
+
* same provider store — e.g. `codex` exec vs `codex-approvals` — must not
|
|
44
|
+
* surface the same on-disk session twice). Best-effort per runtime: one
|
|
45
|
+
* adapter's discovery failing (a missing/unreadable store) never blanks the
|
|
46
|
+
* whole list, mirroring listAllSessions()'s "best effort per agent" contract.
|
|
47
|
+
* Sorted newest-first.
|
|
48
|
+
*/
|
|
49
|
+
export async function collectDiscoveredSessions(runtimes, managed) {
|
|
50
|
+
const managedKeys = managedIdentityKeys(managed);
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const runtime of runtimes) {
|
|
54
|
+
if (!runtime.capabilities.nativeSessionDiscovery || !runtime.discoverNativeSessions)
|
|
55
|
+
continue;
|
|
56
|
+
let discovered;
|
|
57
|
+
try {
|
|
58
|
+
discovered = (await runtime.discoverNativeSessions()) ?? [];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue; // best-effort: a broken adapter shouldn't blank every other one
|
|
62
|
+
}
|
|
63
|
+
for (const session of discovered) {
|
|
64
|
+
if (isAlreadyManaged(session, managedKeys))
|
|
65
|
+
continue;
|
|
66
|
+
// Cross-runtime value dedupe (same on-disk session surfaced by two
|
|
67
|
+
// runtime variants): key on the transcript path when known, else the ref.
|
|
68
|
+
const valueKey = session.file ? `file:${session.file}` : `ref:${session.runtimeId}:${session.ref}`;
|
|
69
|
+
if (seen.has(valueKey))
|
|
70
|
+
continue;
|
|
71
|
+
seen.add(valueKey);
|
|
72
|
+
out.push(session);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decide how importing `session` should proceed, given the OWNING runtime's
|
|
79
|
+
* capabilities. Three outcomes:
|
|
80
|
+
* - "follow-only": a live external process was detected. Bivy has no channel
|
|
81
|
+
* to safely take over an arbitrary foreign process, so adoption is refused
|
|
82
|
+
* and only read-only following (readMessages) is offered.
|
|
83
|
+
* - "native-resume": the runtime can adopt this session with full native
|
|
84
|
+
* context (capabilities.nativeSessionAdoption + resume + session.resumable).
|
|
85
|
+
* - "seeded": adoption is possible but not a true resume — a fresh session
|
|
86
|
+
* seeded from a summary of the prior conversation. Always carries a
|
|
87
|
+
* disclosure the caller must show before importing.
|
|
88
|
+
*/
|
|
89
|
+
export function planNativeAdoption(session, capabilities) {
|
|
90
|
+
if (session.active) {
|
|
91
|
+
return {
|
|
92
|
+
mode: "follow-only",
|
|
93
|
+
disclosure: "This session has a live process running outside Bivy. Close it in its own terminal, then adopt it here — or follow it read-only without adopting.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (capabilities.nativeSessionAdoption && capabilities.resume && session.resumable) {
|
|
97
|
+
return { mode: "native-resume" };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
mode: "seeded",
|
|
101
|
+
disclosure: "Native resume isn't available for this session. Importing starts a new Bivy session seeded with a summary of the prior conversation; the original session is left untouched.",
|
|
102
|
+
};
|
|
103
|
+
}
|