@bivy/bivy 0.0.0 → 0.1.0-staging.10

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 (147) 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 +4215 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/port-picker.mjs +40 -0
  9. package/bin/prune-sessions.mjs +52 -0
  10. package/bin/sessions-list.mjs +27 -0
  11. package/bin/shim-path.mjs +126 -0
  12. package/bin/uninstall-paths.mjs +48 -0
  13. package/dist/approval.js +87 -0
  14. package/dist/attach.js +248 -0
  15. package/dist/auth.js +258 -0
  16. package/dist/bivy-login.js +180 -0
  17. package/dist/browser-open.js +50 -0
  18. package/dist/control-plane-tasks.js +236 -0
  19. package/dist/data-dir.js +25 -0
  20. package/dist/device-registry.js +201 -0
  21. package/dist/e2e.js +70 -0
  22. package/dist/ephemeral-exec.js +109 -0
  23. package/dist/exec.js +209 -0
  24. package/dist/git-auth.js +155 -0
  25. package/dist/github-app-auth.js +107 -0
  26. package/dist/github-app-connect.js +235 -0
  27. package/dist/github-app-manifest.js +82 -0
  28. package/dist/github-app-sync-cli.js +93 -0
  29. package/dist/github-app-vault.js +106 -0
  30. package/dist/github-apps.js +121 -0
  31. package/dist/github-connect-repo.js +74 -0
  32. package/dist/github-device-auth.js +109 -0
  33. package/dist/github-tasks.js +650 -0
  34. package/dist/guard.js +109 -0
  35. package/dist/harness/cache-evict.js +88 -0
  36. package/dist/harness/checkpoint.js +0 -0
  37. package/dist/harness/cow-clone.js +84 -0
  38. package/dist/harness/dep-cache.js +78 -0
  39. package/dist/harness/disk-admission.js +46 -0
  40. package/dist/harness/egress.js +30 -0
  41. package/dist/harness/manager.js +97 -0
  42. package/dist/harness/mcp-config-formats.js +164 -0
  43. package/dist/harness/mcp-config.js +111 -0
  44. package/dist/harness/mcp-inject.js +134 -0
  45. package/dist/harness/mcp-proxy-cli.js +88 -0
  46. package/dist/harness/mcp-proxy.js +150 -0
  47. package/dist/harness/net-proxy.js +120 -0
  48. package/dist/harness/sandbox.js +96 -0
  49. package/dist/history-sync.js +26 -0
  50. package/dist/hosted-endpoints.d.mts +14 -0
  51. package/dist/hosted-endpoints.mjs +35 -0
  52. package/dist/identity.js +153 -0
  53. package/dist/integrations/index.js +4 -0
  54. package/dist/integrations/manager.js +279 -0
  55. package/dist/integrations/oauth.js +78 -0
  56. package/dist/integrations/registry.js +239 -0
  57. package/dist/integrations/store.js +54 -0
  58. package/dist/integrations/types.js +1 -0
  59. package/dist/linear-tasks.js +49 -0
  60. package/dist/metadata.js +226 -0
  61. package/dist/multiplexer.js +79 -0
  62. package/dist/native-pi.js +38 -0
  63. package/dist/node-stats.js +237 -0
  64. package/dist/pairing-crypto.js +105 -0
  65. package/dist/policy/conditions.js +103 -0
  66. package/dist/policy/policy-engine.js +20 -0
  67. package/dist/policy/risk.js +18 -0
  68. package/dist/policy/ruleset.js +113 -0
  69. package/dist/policy/run-policy.js +108 -0
  70. package/dist/policy/session-reroute.js +96 -0
  71. package/dist/pty-runner.py +95 -0
  72. package/dist/question.js +146 -0
  73. package/dist/redact.js +97 -0
  74. package/dist/relay-attach.js +345 -0
  75. package/dist/relay-chunk.js +73 -0
  76. package/dist/relay-cli-crypto.js +70 -0
  77. package/dist/relay-client.js +372 -0
  78. package/dist/relay-setup.js +262 -0
  79. package/dist/repo-workspace.js +208 -0
  80. package/dist/runtime/adoption.js +45 -0
  81. package/dist/runtime/agent-service-bin.js +149 -0
  82. package/dist/runtime/agent-service.js +439 -0
  83. package/dist/runtime/ansi.js +27 -0
  84. package/dist/runtime/anthropic-preflight.js +80 -0
  85. package/dist/runtime/claude-code.js +1408 -0
  86. package/dist/runtime/cli-parsers.js +647 -0
  87. package/dist/runtime/codex-auth.js +168 -0
  88. package/dist/runtime/codex-preflight.js +60 -0
  89. package/dist/runtime/codex-sessions.js +279 -0
  90. package/dist/runtime/control-plane-location.js +74 -0
  91. package/dist/runtime/credential-ingest.js +122 -0
  92. package/dist/runtime/credential-provisioning.js +79 -0
  93. package/dist/runtime/credential-store.js +435 -0
  94. package/dist/runtime/credentials.js +153 -0
  95. package/dist/runtime/host.js +153 -0
  96. package/dist/runtime/index.js +1552 -0
  97. package/dist/runtime/local-model-store.js +194 -0
  98. package/dist/runtime/location-registry.js +28 -0
  99. package/dist/runtime/model-catalog.js +97 -0
  100. package/dist/runtime/model-namer.js +85 -0
  101. package/dist/runtime/native-process-scan.js +102 -0
  102. package/dist/runtime/native-session-discovery.js +103 -0
  103. package/dist/runtime/normalize.js +75 -0
  104. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  105. package/dist/runtime/oauth/model-oauth.js +324 -0
  106. package/dist/runtime/opencode-preflight.js +55 -0
  107. package/dist/runtime/pi-auth.js +82 -0
  108. package/dist/runtime/pi-oauth.js +52 -0
  109. package/dist/runtime/pi-session-discovery.js +42 -0
  110. package/dist/runtime/pi.js +539 -0
  111. package/dist/runtime/process.js +499 -0
  112. package/dist/runtime/protocol.js +646 -0
  113. package/dist/runtime/remote.js +541 -0
  114. package/dist/runtime/rpc-protocol.js +56 -0
  115. package/dist/runtime/ruleset-store.js +117 -0
  116. package/dist/runtime/session-location.js +50 -0
  117. package/dist/runtime/types.js +17 -0
  118. package/dist/secrets-cli.js +134 -0
  119. package/dist/secrets.js +264 -0
  120. package/dist/server.js +9435 -0
  121. package/dist/session/bivy-session.js +1 -0
  122. package/dist/session/checkpoint-pack.js +133 -0
  123. package/dist/session/event-log.js +340 -0
  124. package/dist/session/fork-dirty.js +73 -0
  125. package/dist/session/fork-prereqs.js +61 -0
  126. package/dist/session/fork.js +90 -0
  127. package/dist/session/native-import.js +56 -0
  128. package/dist/session/reconnect.js +168 -0
  129. package/dist/session/replication-service.js +236 -0
  130. package/dist/session/replication.js +106 -0
  131. package/dist/session/replicator.js +140 -0
  132. package/dist/session/session-new-dedupe.js +42 -0
  133. package/dist/session/sibling-client.js +201 -0
  134. package/dist/session/transcript-merge.js +131 -0
  135. package/dist/session/transcript-normal.js +195 -0
  136. package/dist/session/workspace-context.js +1 -0
  137. package/dist/session-event-coalescer.js +50 -0
  138. package/dist/session-identity.js +34 -0
  139. package/dist/session-ref.js +65 -0
  140. package/dist/stt-cli.js +131 -0
  141. package/dist/stt.js +168 -0
  142. package/dist/terminal.js +409 -0
  143. package/dist/wire-format.js +67 -0
  144. package/dist/worktree-provision.js +118 -0
  145. package/dist/worktree.js +117 -0
  146. package/package.json +40 -6
  147. package/public/qr.js +464 -0
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex credential materialization.
4
+ //
5
+ // A ChatGPT/Codex subscription a user connects *inside Bivy* lands in the shared
6
+ // node vault as an `openai-codex` OAuth record. The Codex CLI can't
7
+ // read that vault — it authenticates from `OPENAI_API_KEY` or its own
8
+ // `$CODEX_HOME/auth.json` (the file `codex login` writes). This module bridges the
9
+ // gap: it mints the file Codex expects from the vault record, so a subscription
10
+ // connected in Bivy "just works" for Codex with no separate `codex login`.
11
+ //
12
+ // Why it's sound: Pi's `openai-codex` OAuth app IS the Codex CLI's own OAuth app
13
+ // (identical client_id, token endpoint, and scopes — verified against the codex
14
+ // binary), so tokens minted through Bivy are accepted by Codex's backend. The
15
+ // vault stores {access, refresh, expires, accountId} but NOT the `id_token`
16
+ // Codex's auth.json requires; we recover it with a `refresh_token` grant. The
17
+ // original grant used `scope=openid`, so the refresh response carries a fresh
18
+ // `id_token` — the same call Codex itself makes to refresh.
19
+ //
20
+ // Rotation note: OpenAI rotates the refresh token on every grant, so we persist
21
+ // the rotated token back to the vault. We mint only when no auth.json exists yet
22
+ // (Codex then owns and self-refreshes it), which keeps churn to a single grant.
23
+ // The residual edge case — Codex self-refreshing later invalidates the vault's
24
+ // copy for *other* `openai-codex` consumers — is documented; for the common case
25
+ // (the subscription was connected for Codex) there are no other consumers.
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { createCredentialVault } from "./credential-store.js";
30
+ // Codex's own OAuth app — verified identical to the app Pi's `openai-codex` login
31
+ // uses, so a refresh grant minted here is honored by Codex's backend.
32
+ const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
33
+ const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
34
+ /** Resolve Codex's home dir exactly as the CLI (and our rollout reader) does. */
35
+ export function resolveCodexHome() {
36
+ return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
37
+ }
38
+ /**
39
+ * Pre-trust a workspace in Codex's `config.toml` so a `bivy run codex` launched
40
+ * there doesn't stall on the first-run "Do you trust the contents of this
41
+ * directory?" gate — which, unanswered, blocks Codex before it writes a rollout
42
+ * (and so blocks "continue as chat" takeover, which resumes by that rollout id).
43
+ *
44
+ * We only ADD a missing `[projects."<path>"]` trust entry; we never rewrite or
45
+ * downgrade an existing one (a user may have deliberately set a different level).
46
+ * Best-effort and idempotent: any failure just leaves the interactive prompt in
47
+ * place, exactly as before. Mirrors what choosing "Yes, continue" would persist.
48
+ */
49
+ export function ensureCodexTrusted(workspace) {
50
+ const dir = workspace?.trim();
51
+ if (!dir)
52
+ return;
53
+ try {
54
+ const codexHome = resolveCodexHome();
55
+ const configFile = path.join(codexHome, "config.toml");
56
+ let existing = "";
57
+ try {
58
+ existing = fs.readFileSync(configFile, "utf8");
59
+ }
60
+ catch { /* no config yet — we'll create one */ }
61
+ // TOML basic-string key: escape backslashes and double quotes.
62
+ const escaped = dir.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
63
+ const header = `[projects."${escaped}"]`;
64
+ // Already has a table header for this exact path → leave its trust_level be.
65
+ if (existing.split(/\r?\n/).some((line) => line.trim() === header))
66
+ return;
67
+ const prefix = existing.length && !existing.endsWith("\n") ? "\n" : "";
68
+ const block = `${prefix}\n${header}\ntrust_level = "trusted"\n`;
69
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
70
+ fs.appendFileSync(configFile, block);
71
+ }
72
+ catch { /* best effort — an untrusted prompt is a soft failure, not a hard one */ }
73
+ }
74
+ /** Exchange a refresh token for a fresh token set (incl. the `id_token`). */
75
+ async function refreshCodexTokens(refreshToken) {
76
+ let res;
77
+ try {
78
+ res = await fetch(CODEX_TOKEN_URL, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
81
+ body: new URLSearchParams({
82
+ grant_type: "refresh_token",
83
+ refresh_token: refreshToken,
84
+ client_id: CODEX_OAUTH_CLIENT_ID,
85
+ }),
86
+ });
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ if (!res.ok)
92
+ return undefined;
93
+ const j = (await res.json().catch(() => ({})));
94
+ const idToken = typeof j.id_token === "string" ? j.id_token : "";
95
+ const accessToken = typeof j.access_token === "string" ? j.access_token : "";
96
+ const refreshTok = typeof j.refresh_token === "string" ? j.refresh_token : "";
97
+ const expiresIn = typeof j.expires_in === "number" ? j.expires_in : 0;
98
+ if (!idToken || !accessToken || !refreshTok)
99
+ return undefined;
100
+ return { idToken, accessToken, refreshToken: refreshTok, expiresIn };
101
+ }
102
+ /**
103
+ * Ensure the Codex CLI has a usable credential file, minting one from Bivy's
104
+ * vault when needed. Returns the resolved `CODEX_HOME` (so the caller can pin it
105
+ * on the subprocess env) or `undefined` when there's nothing to do / no vault
106
+ * credential — in which case the caller's preflight surfaces the actionable
107
+ * "no credential" error unchanged.
108
+ *
109
+ * Idempotent and low-churn: if an `auth.json` already exists (a native
110
+ * `codex login` or a prior materialization) it is left untouched — Codex owns and
111
+ * self-refreshes it — so we mint at most once. We write to the *default* Codex
112
+ * home (never a throwaway dir) so Codex's rollouts stay where the session reader
113
+ * looks.
114
+ */
115
+ export async function ensureCodexAuth(credsDir) {
116
+ const codexHome = resolveCodexHome();
117
+ const authFile = path.join(codexHome, "auth.json");
118
+ // Never clobber an existing login (native or previously materialized); Codex
119
+ // owns and refreshes it. An OPENAI_API_KEY, if present, is handled by preflight.
120
+ if (fs.existsSync(authFile))
121
+ return codexHome;
122
+ if (process.env.OPENAI_API_KEY?.trim())
123
+ return undefined;
124
+ const store = createCredentialVault(credsDir);
125
+ const cred = await store.read("openai-codex").catch(() => undefined);
126
+ if (!cred || cred.type !== "oauth" || typeof cred.refresh !== "string" || !cred.refresh)
127
+ return undefined;
128
+ const refreshed = await refreshCodexTokens(cred.refresh);
129
+ if (!refreshed)
130
+ return undefined;
131
+ // Persist the rotated refresh token back to the vault FIRST — OpenAI rotates it
132
+ // on every grant, so the previous one is now dead. If we can't persist it, bail
133
+ // rather than strand the vault on a token we've just invalidated.
134
+ try {
135
+ await store.modify("openai-codex", async (current) => ({
136
+ ...(current ?? cred),
137
+ type: "oauth",
138
+ access: refreshed.accessToken,
139
+ refresh: refreshed.refreshToken,
140
+ expires: Date.now() + refreshed.expiresIn * 1000,
141
+ }));
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ const accountId = typeof cred.accountId === "string" ? cred.accountId : undefined;
147
+ const authJson = {
148
+ auth_mode: "chatgpt",
149
+ OPENAI_API_KEY: null,
150
+ tokens: {
151
+ id_token: refreshed.idToken,
152
+ access_token: refreshed.accessToken,
153
+ refresh_token: refreshed.refreshToken,
154
+ account_id: accountId,
155
+ },
156
+ last_refresh: new Date().toISOString(),
157
+ };
158
+ try {
159
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
160
+ const tmp = `${authFile}.tmp`;
161
+ fs.writeFileSync(tmp, `${JSON.stringify(authJson, null, 2)}\n`, { mode: 0o600 });
162
+ fs.renameSync(tmp, authFile);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ return codexHome;
168
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex credential preflight.
4
+ //
5
+ // The Codex CLI authenticates one of two ways: an OpenAI API key read from the
6
+ // `OPENAI_API_KEY` environment variable, or tokens in `CODEX_HOME/auth.json`
7
+ // (default `~/.codex/auth.json`). Bivy's shared credential vault forwards *API
8
+ // keys* to every agent (see credentials.ts), and `codex-auth.ts` mints the
9
+ // auth.json from a connected ChatGPT/Codex *subscription* — so both sign-in kinds
10
+ // now reach Codex.
11
+ //
12
+ // This preflight is the backstop: if neither an API key nor an auth.json is
13
+ // present after that handoff (e.g. no OpenAI sign-in at all, or a saved
14
+ // subscription that could not be refreshed), `codex exec` would fail with the
15
+ // opaque upstream error `unexpected status 401 Unauthorized: Missing bearer or
16
+ // basic authentication in header`. We detect that up front and return a clear,
17
+ // actionable message instead of letting the subprocess spawn and die on a 401.
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ /** Path to the Codex CLI's own auth file (`CODEX_HOME/auth.json`, default `~/.codex`). */
22
+ export function codexAuthFile(deps = {}) {
23
+ const codexHome = (deps.codexHome ?? process.env.CODEX_HOME)?.trim();
24
+ const base = codexHome ? codexHome : path.join(deps.home ?? os.homedir(), ".codex");
25
+ return path.join(base, "auth.json");
26
+ }
27
+ /** The user-facing message shown when Codex has no OpenAI credential. */
28
+ export const CODEX_NO_CREDENTIAL_MESSAGE = [
29
+ "Codex has no OpenAI credential on this node, so it can't start a turn — it would fail with `401 Unauthorized: Missing bearer or basic authentication in header`.",
30
+ "",
31
+ "Fix it any of these ways:",
32
+ "• Connect ChatGPT Plus/Pro (Codex) under Keys & OAuth — Bivy mints Codex's auth file from it automatically, or",
33
+ "• Add an OpenAI API key under Keys & OAuth — Bivy passes it to Codex as OPENAI_API_KEY, or",
34
+ "• Run `codex login` on this node to sign in directly.",
35
+ "",
36
+ "If you already connected the ChatGPT/Codex subscription in Bivy and still see this, the saved login could not be refreshed — reconnect it under Keys & OAuth.",
37
+ ].join("\n");
38
+ /**
39
+ * Returns a human-readable error when the Codex CLI has no usable OpenAI
40
+ * credential (no `OPENAI_API_KEY` in the resolved environment and no
41
+ * `codex login` auth file), or undefined when a credential is present so the
42
+ * turn should proceed. `env` is the environment the Codex subprocess will run
43
+ * with (ambient process env + Bivy's vault handoff), so this catches both an
44
+ * OpenAI API key stored in Bivy and one already present on the node.
45
+ */
46
+ export function codexCredentialPreflight(env, deps = {}) {
47
+ if (env.OPENAI_API_KEY?.trim())
48
+ return undefined;
49
+ const exists = deps.fileExists ?? ((p) => {
50
+ try {
51
+ return fs.existsSync(p);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ });
57
+ if (exists(codexAuthFile(deps)))
58
+ return undefined;
59
+ return CODEX_NO_CREDENTIAL_MESSAGE;
60
+ }
@@ -0,0 +1,279 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Codex on-disk session ("rollout") reader — the read side of adopting a Codex
4
+ // session that was started outside Bivy (e.g. a bare `codex` in a terminal).
5
+ //
6
+ // The Codex CLI ("@openai/codex") persists each session as a JSONL rollout under
7
+ // $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
8
+ // (CODEX_HOME defaults to ~/.codex). The first line carries session metadata
9
+ // (id, cwd, timestamp); subsequent lines are response/event items.
10
+ //
11
+ // IMPORTANT — best-effort, and NOT yet verified against a live Codex here:
12
+ // Codex's rollout record shape has changed across versions, so every read is
13
+ // defensive (unknown/malformed lines are skipped, never thrown), and both a
14
+ // wrapped (`{type,payload}`) and a flat record layout are tolerated. This module
15
+ // only *reconstructs history* and *locates* sessions; a governed live resume of a
16
+ // Codex session additionally needs the Codex runtime to gain resume +
17
+ // toolInterception (today it is a non-governing process runtime — see
18
+ // docs/agent-runtimes.md and src/runtime/index.ts `cliAgentInfo`). Unlike Claude,
19
+ // Codex has no stable launch-time session-id flag to pin, so adoption relies on
20
+ // locating the rollout a run produced (see discoverCodexSessionForCwd).
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { randomUUID } from "node:crypto";
25
+ import { hasLiveProcessForCwd } from "./native-process-scan.js";
26
+ /** Binary names a live Codex process could be running under (see
27
+ * native-process-scan.ts's best-effort cwd match). */
28
+ const CODEX_BIN_NAMES = ["codex"];
29
+ export function codexHome() {
30
+ return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
31
+ }
32
+ export function codexSessionsDir() {
33
+ return path.join(codexHome(), "sessions");
34
+ }
35
+ /** Unwrap a `{ type, payload }` envelope, else return the record as-is. */
36
+ function inner(rec) {
37
+ const r = (rec ?? {});
38
+ return r.payload && typeof r.payload === "object" ? r.payload : r;
39
+ }
40
+ function toEpoch(value) {
41
+ if (value == null)
42
+ return undefined;
43
+ const ms = new Date(value).getTime();
44
+ return Number.isFinite(ms) ? ms : undefined;
45
+ }
46
+ /** Recursively collect `rollout-*.jsonl` files under a Codex sessions dir. */
47
+ function findRolloutFiles(root) {
48
+ const out = [];
49
+ const walk = (dir) => {
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return; // missing/unreadable — best-effort
56
+ }
57
+ for (const entry of entries) {
58
+ const full = path.join(dir, entry.name);
59
+ if (entry.isDirectory())
60
+ walk(full);
61
+ else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name))
62
+ out.push(full);
63
+ }
64
+ };
65
+ walk(root);
66
+ return out;
67
+ }
68
+ /** Session metadata from a rollout's first line (id / cwd / start time). */
69
+ function metaFromFirstLine(line) {
70
+ try {
71
+ const rec = JSON.parse(line);
72
+ const p = inner(rec);
73
+ const id = typeof p.id === "string" ? p.id : typeof p.session_id === "string" ? p.session_id : undefined;
74
+ const cwd = typeof p.cwd === "string" ? p.cwd : undefined;
75
+ const createdAt = toEpoch(p.timestamp ?? rec.timestamp);
76
+ return { id, cwd, createdAt };
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ /** Extract plain text from a Codex content field (string or content-block array). */
83
+ function textOf(content) {
84
+ if (typeof content === "string")
85
+ return content;
86
+ if (!Array.isArray(content))
87
+ return "";
88
+ return content
89
+ .map((block) => {
90
+ const b = (block ?? {});
91
+ if (typeof b.text === "string")
92
+ return b.text;
93
+ return "";
94
+ })
95
+ .join("")
96
+ .trim();
97
+ }
98
+ /**
99
+ * Reconstruct a session's conversation from its rollout file as normalized
100
+ * RuntimeMessages (role + content + timestamp) — the same shape loadClaudeTranscript
101
+ * produces, so a reopened Codex session paints prior history the same way. Skips
102
+ * meta/system/tooling records; keeps user and assistant turns. Best-effort.
103
+ */
104
+ export function loadCodexTranscriptFile(file) {
105
+ const messages = [];
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(file, "utf8");
109
+ }
110
+ catch {
111
+ return messages;
112
+ }
113
+ for (const line of raw.split(/\r?\n/)) {
114
+ if (!line.trim())
115
+ continue;
116
+ let rec;
117
+ try {
118
+ rec = JSON.parse(line);
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ const p = inner(rec);
124
+ // A message item: either {role, content} directly or an item with type "message".
125
+ const role = p.role ?? (p.type === "message" ? p.role : undefined);
126
+ if (role !== "user" && role !== "assistant")
127
+ continue;
128
+ const content = p.content ?? p.text;
129
+ const text = textOf(content);
130
+ if (!text)
131
+ continue;
132
+ messages.push({ role, content: text, timestamp: toEpoch(p.timestamp ?? rec.timestamp) ?? Date.now() });
133
+ }
134
+ return messages;
135
+ }
136
+ /**
137
+ * Materialise a **cross-runtime** fork's portable history as a fresh Codex
138
+ * rollout so `codex ... resume <id>` (the app-server's `thread/resume`) opens on
139
+ * a copy of the whole conversation — the write-side counterpart to
140
+ * `loadCodexTranscript`, and Codex's `importHistoryForFork` (fidelity
141
+ * "replayed"). The rollout is written in the current wrapped layout — a
142
+ * `session_meta` line then one `response_item` per turn — under the id-addressed
143
+ * date path Codex uses (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), the
144
+ * same shape `loadCodexTranscriptFile` reads back and `discoverCodexSessionForCwd`
145
+ * locates. Message turns carry a Responses-API `message` item (`input_text` for
146
+ * the user, `output_text` for the assistant), which reads back through `textOf`.
147
+ *
148
+ * IMPORTANT — best-effort, and NOT verified against a live Codex resume here (see
149
+ * this module's header): Codex's rollout schema is version-variable, so whether a
150
+ * *synthesised* rollout is fully honored by `thread/resume` depends on the
151
+ * installed Codex. The fork engine calls this only as its "replayed" tier and
152
+ * falls back to a seeded continuation prompt if it throws; a node can force that
153
+ * fallback outright with `BIVY_CODEX_NO_FORK_REPLAY=1` when its Codex build
154
+ * doesn't accept synthesised rollouts.
155
+ */
156
+ export function writeCodexRollout(history, cwd) {
157
+ if (process.env.BIVY_CODEX_NO_FORK_REPLAY === "1") {
158
+ throw new Error("Codex fork replay disabled (BIVY_CODEX_NO_FORK_REPLAY=1)");
159
+ }
160
+ const id = randomUUID();
161
+ const now = new Date();
162
+ const iso = now.toISOString();
163
+ const yyyy = String(now.getUTCFullYear());
164
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
165
+ const dd = String(now.getUTCDate()).padStart(2, "0");
166
+ const dir = path.join(codexSessionsDir(), yyyy, mm, dd);
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ const stamp = iso.replace(/[:.]/g, "-").replace(/Z$/, "");
169
+ const file = path.join(dir, `rollout-${stamp}-${id}.jsonl`);
170
+ const records = [
171
+ { type: "session_meta", timestamp: iso, payload: { id, timestamp: iso, cwd, cli_version: "bivy-fork" } },
172
+ ...history.map((message) => ({
173
+ type: "response_item",
174
+ timestamp: iso,
175
+ payload: {
176
+ type: "message",
177
+ role: message.role,
178
+ content: [{ type: message.role === "user" ? "input_text" : "output_text", text: message.text }],
179
+ },
180
+ })),
181
+ ];
182
+ fs.writeFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n");
183
+ return { sessionFile: id, id };
184
+ }
185
+ /** Enumerate Codex sessions on disk, newest first. Best-effort. */
186
+ export function listCodexSessions() {
187
+ const sessions = [];
188
+ for (const file of findRolloutFiles(codexSessionsDir())) {
189
+ let firstLine = "";
190
+ let mtime;
191
+ try {
192
+ mtime = fs.statSync(file).mtimeMs;
193
+ const fd = fs.readFileSync(file, "utf8");
194
+ firstLine = fd.split(/\r?\n/, 1)[0] ?? "";
195
+ }
196
+ catch {
197
+ continue;
198
+ }
199
+ const meta = metaFromFirstLine(firstLine);
200
+ const transcript = loadCodexTranscriptFile(file);
201
+ // Codex prepends synthetic context turns as the first "user" messages —
202
+ // `<environment_context>`, `<recommended_plugins>`, `<user_instructions>`,
203
+ // etc. — which make useless list labels. Prefer the first turn that isn't one
204
+ // of these XML-ish blocks (i.e. the user's real prompt), falling back to the
205
+ // first turn only if that's genuinely all there is.
206
+ const userTurns = transcript.filter((m) => m.role === "user");
207
+ const contentOf = (m) => String(m?.content ?? "");
208
+ const isInjectedBlock = (text) => /^<[a-z][\w-]*>/i.test(text.trimStart());
209
+ const firstUser = userTurns.find((m) => !isInjectedBlock(contentOf(m))) ?? userTurns[0];
210
+ const firstMessage = firstUser ? contentOf(firstUser).slice(0, 200) : undefined;
211
+ sessions.push({
212
+ id: meta.id,
213
+ file,
214
+ cwd: meta.cwd,
215
+ createdAt: meta.createdAt ?? mtime,
216
+ firstMessage,
217
+ });
218
+ }
219
+ return sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
220
+ }
221
+ /** Load a Codex session's transcript by its session id (searches the store). */
222
+ export function loadCodexTranscript(sessionId) {
223
+ const match = listCodexSessions().find((s) => s.id === sessionId);
224
+ return match ? loadCodexTranscriptFile(match.file) : [];
225
+ }
226
+ /**
227
+ * Delete a Codex session's rollout file by id so a user-initiated delete in the
228
+ * app actually removes it from Codex's store (`$CODEX_HOME/sessions/**`) — the
229
+ * read-side counterpart to loadCodexTranscript. Returns true if a rollout was
230
+ * unlinked. Best-effort: a missing store / already-gone file is not an error.
231
+ */
232
+ export function deleteCodexSession(sessionId) {
233
+ const match = listCodexSessions().find((s) => s.id === sessionId);
234
+ if (!match)
235
+ return false;
236
+ try {
237
+ fs.unlinkSync(match.file);
238
+ return true;
239
+ }
240
+ catch (error) {
241
+ if (error?.code === "ENOENT")
242
+ return false;
243
+ throw error;
244
+ }
245
+ }
246
+ /**
247
+ * Locate the Codex session a run produced, for adoption when there is no pinned
248
+ * id: the newest rollout whose recorded cwd matches `cwd` and that started at/after
249
+ * `since` (the run-terminal's creation time, minus a small skew). Returns the
250
+ * session summary or undefined. Best-effort — Codex assigns the id itself, so this
251
+ * is how a `bivy run codex` session is mapped back to its on-disk rollout.
252
+ */
253
+ export function discoverCodexSessionForCwd(cwd, since = 0) {
254
+ const target = path.resolve(cwd);
255
+ const skewMs = 5_000;
256
+ return listCodexSessions().find((s) => s.cwd != null && path.resolve(s.cwd) === target && (s.createdAt ?? 0) >= since - skewMs);
257
+ }
258
+ /**
259
+ * Enumerate Codex's on-disk sessions as bounded discovery metadata (issue
260
+ * #156's runtime-agnostic discovery/adoption flow — see
261
+ * native-session-discovery.ts). Every Codex session Codex itself assigned an
262
+ * id to is resumable via `codex exec resume <id>`, so `resumable` is true
263
+ * whenever an id was recorded; `active` is a best-effort live-process check
264
+ * scoped to this session's own cwd, never its transcript content.
265
+ */
266
+ export function discoverNativeCodexSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, CODEX_BIN_NAMES)) {
267
+ return listCodexSessions()
268
+ .filter((s) => Boolean(s.id))
269
+ .map((s) => ({
270
+ runtimeId: "codex-approvals",
271
+ ref: s.id,
272
+ file: s.file,
273
+ cwd: s.cwd,
274
+ updatedAt: s.createdAt,
275
+ title: s.firstMessage,
276
+ active: Boolean(s.cwd) && hasLiveProcess(s.cwd),
277
+ resumable: true,
278
+ }));
279
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ export class ControlPlaneSessionLocationRegistry {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ /** Map a durable row to a routable location, or undefined if not adoptable. */
9
+ toLocation(row) {
10
+ if (!row.sessionId || !row.agentServiceAddress)
11
+ return undefined;
12
+ const runtimeId = this.options.resolveRuntimeId(row.sessionId);
13
+ if (!runtimeId)
14
+ return undefined; // can't route an attach without the runtime id
15
+ return { sessionId: row.sessionId, agentServiceAddress: row.agentServiceAddress, runtimeId, nodeId: this.options.nodeId };
16
+ }
17
+ async lookup(sessionId) {
18
+ if (!sessionId)
19
+ return undefined;
20
+ const rows = await this.options.fetchNodeSessions().catch(() => []);
21
+ const row = rows.find((r) => r.sessionId === sessionId);
22
+ return row ? this.toLocation(row) : undefined;
23
+ }
24
+ /**
25
+ * Every adoptable session this node currently owns in the control plane (has a
26
+ * host address AND a resolvable runtime id). Startup adoption iterates this and
27
+ * re-attaches to each. Never throws — a fetch failure yields `[]`.
28
+ */
29
+ async listNode() {
30
+ const rows = await this.options.fetchNodeSessions().catch(() => []);
31
+ const out = [];
32
+ for (const row of rows) {
33
+ const location = this.toLocation(row);
34
+ if (location)
35
+ out.push(location);
36
+ }
37
+ return out;
38
+ }
39
+ // Writes are owned by advertiseSessions (the replace-all POST to /node/sessions
40
+ // is the single writer of session_index); this is a read-through view.
41
+ async record(_location) {
42
+ /* no-op: advertise owns control-plane writes */
43
+ }
44
+ async forget(_sessionId) {
45
+ /* no-op: advertise owns control-plane writes */
46
+ }
47
+ }
48
+ /**
49
+ * Layers two registries so a lookup prefers the fast, authoritative in-memory
50
+ * entry (this daemon's own live sessions) and falls through to a shared,
51
+ * durable one (the control plane) only on a miss — exactly the "in-memory first,
52
+ * then control-plane" ordering Stage 3 needs so nothing regresses when the
53
+ * process already knows a session. Writes (`record`/`forget`) go to the primary
54
+ * (in-memory) layer; the control-plane layer's writes are no-ops by design.
55
+ */
56
+ export class LayeredSessionLocationRegistry {
57
+ primary;
58
+ fallback;
59
+ constructor(primary, fallback) {
60
+ this.primary = primary;
61
+ this.fallback = fallback;
62
+ }
63
+ async record(location) {
64
+ await this.primary.record(location);
65
+ await this.fallback.record(location);
66
+ }
67
+ async lookup(sessionId) {
68
+ return (await this.primary.lookup(sessionId)) ?? (await this.fallback.lookup(sessionId));
69
+ }
70
+ async forget(sessionId) {
71
+ await this.primary.forget(sessionId);
72
+ await this.fallback.forget(sessionId);
73
+ }
74
+ }