@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,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
+ }