@bivy/bivy 0.16.1-staging.3 → 0.16.1-staging.5

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/bin/bivy.mjs CHANGED
@@ -34,11 +34,13 @@ import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash,
34
34
  import { fileURLToPath, pathToFileURL } from "node:url";
35
35
  import vm from "node:vm";
36
36
  import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
37
- import { resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
37
+ import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
38
38
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
39
39
  import { removeInstallAndState } from "./uninstall-paths.mjs";
40
40
  import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
41
41
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
42
+ import { detectInstallKind as classifyInstallKind } from "./install-kind.mjs";
43
+ import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
42
44
 
43
45
  const selfScript = fileURLToPath(import.meta.url);
44
46
  const __dirname = path.dirname(selfScript);
@@ -69,14 +71,10 @@ process.env.BIVY_DATA_DIR = appDir;
69
71
  // background service can point at repoRoot:
70
72
  // - "git" dev checkout (has .git)
71
73
  // - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
72
- // - "npm-global" `npm i -g @bivy/bivy` (repoRoot's parent dir is node_modules)
74
+ // - "npm-global" `npm i -g @bivy/bivy` (repoRoot is below node_modules/@bivy)
73
75
  // - "packaged" install.sh tarball tree (user-owned, self-preserving)
74
76
  function detectInstallKind() {
75
- if (fs.existsSync(path.join(repoRoot, ".git"))) return "git";
76
- const inNodeModules = path.basename(path.dirname(repoRoot)) === "node_modules";
77
- if (inNodeModules && /[\\/]_npx[\\/]/.test(repoRoot)) return "npx";
78
- if (inNodeModules) return "npm-global";
79
- return "packaged";
77
+ return classifyInstallKind(repoRoot);
80
78
  }
81
79
  const cliConfigPath = path.join(appDir, "cli.json");
82
80
  const canonicalConfigPath = path.join(appDir, "config.yaml");
@@ -1180,6 +1178,7 @@ const AGENT_RESUME_ARGS = {
1180
1178
  codex: (id) => ["resume", id],
1181
1179
  grok: (id) => ["--resume", id],
1182
1180
  gemini: (id) => ["--resume", id],
1181
+ qwen: (id) => ["--resume", id],
1183
1182
  // `opencode -s, --session session id to continue` (TUI), per `opencode --help`.
1184
1183
  opencode: (id) => ["--session", id],
1185
1184
  };
@@ -2430,7 +2429,11 @@ async function resumeSessionItem(item, config, token) {
2430
2429
  console.log(c.yellow(`"${item.name}" (${item.agentName || item.agent}) has no native terminal resume; open it in the web app with 'bivy open'.`));
2431
2430
  return;
2432
2431
  }
2433
- const resumeArgs = agentResumeArgs(agentId, item.id || item.ref);
2432
+ // Resume with the provider's durable ref, not Bivy's canonical id. They
2433
+ // differ for imported/forked sessions (notably Codex), and passing the Bivy id
2434
+ // makes the native CLI report "No saved session found".
2435
+ const resumeRef = nativeResumeRef(item);
2436
+ const resumeArgs = agentResumeArgs(agentId, resumeRef);
2434
2437
  const runArgs = [agentId, ...resumeArgs];
2435
2438
  if (item.workspace) runArgs.push("--workspace", item.workspace); // native resume finds the session by its original cwd
2436
2439
  console.log(c.dim(`Resuming ${c.cyan(item.name)} with ${agentId} ${resumeArgs.join(" ")}…`));
@@ -3254,6 +3257,10 @@ async function reconcileNodePort(config) {
3254
3257
  // reloads/relaunches). Otherwise a plain restart. Returns true if the service
3255
3258
  // was (re)started. Used by `bivy restart` and `bivy update` — the paths that
3256
3259
  // previously trusted the saved port verbatim.
3260
+ function hasConfiguredService(config) {
3261
+ return configuredServiceExists(config, servicePaths().file);
3262
+ }
3263
+
3257
3264
  async function restartServiceReconciled(config) {
3258
3265
  const { kind, file } = servicePaths();
3259
3266
  if (!fs.existsSync(file)) return restartService();
@@ -4442,7 +4449,7 @@ async function runUpdate(args = []) {
4442
4449
  await ensureKnownAgents();
4443
4450
  const config = loadConfig();
4444
4451
  await waitForIdleSessions(config, { skip: skipWait });
4445
- if (config.service && (await restartServiceReconciled(config))) {
4452
+ if (hasConfiguredService(config) && (await restartServiceReconciled(config))) {
4446
4453
  if (await verifyNodeCameUp(config)) {
4447
4454
  console.log(c.green("Updated and restarted the background service."));
4448
4455
  } else {
@@ -4474,7 +4481,7 @@ async function runUpdate(args = []) {
4474
4481
  });
4475
4482
  // install.sh restarts the service itself; verify the node actually came up
4476
4483
  // rather than trusting the installer's exit code over a crash-looping node.
4477
- if (code === 0 && config.service && !(await verifyNodeCameUp(config))) {
4484
+ if (code === 0 && hasConfiguredService(config) && !(await verifyNodeCameUp(config))) {
4478
4485
  reportNodeDidNotStart();
4479
4486
  process.exit(1);
4480
4487
  }
@@ -4490,7 +4497,7 @@ async function runUpdate(args = []) {
4490
4497
  await ensureKnownAgents();
4491
4498
  const config = loadConfig();
4492
4499
  await waitForIdleSessions(config, { skip: skipWait });
4493
- if (config.service && (await restartServiceReconciled(config))) {
4500
+ if (hasConfiguredService(config) && (await restartServiceReconciled(config))) {
4494
4501
  if (await verifyNodeCameUp(config)) {
4495
4502
  console.log(c.green("Updated and restarted the background service."));
4496
4503
  } else {
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ /** Classify a Bivy package root without assuming unscoped npm package layout. */
7
+ export function detectInstallKind(repoRoot, existsSync = fs.existsSync) {
8
+ if (existsSync(path.join(repoRoot, ".git"))) return "git";
9
+
10
+ // npm installs an unscoped package at node_modules/name and a scoped package
11
+ // at node_modules/@scope/name. Bivy uses the latter layout.
12
+ const parent = path.dirname(repoRoot);
13
+ const grandparent = path.dirname(parent);
14
+ const inNodeModules = path.basename(parent) === "node_modules"
15
+ || (path.basename(parent).startsWith("@") && path.basename(grandparent) === "node_modules");
16
+
17
+ if (inNodeModules && /[\\/]_npx[\\/]/.test(repoRoot)) return "npx";
18
+ if (inNodeModules) return "npm-global";
19
+ return "packaged";
20
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+
5
+ /**
6
+ * Whether an install has a background service that an update must restart.
7
+ * The on-disk unit/plist is authoritative; cli.json.service is only a hint and
8
+ * may be absent on migrated installs.
9
+ */
10
+ export function hasConfiguredService(config, serviceFile, existsSync = fs.existsSync) {
11
+ return Boolean(config?.service) || existsSync(serviceFile);
12
+ }
@@ -25,3 +25,14 @@ export function resolveSessionsLimit(rawArg) {
25
25
  export function truncateSavedSessions(sessions, limit) {
26
26
  return sessions.slice(0, limit);
27
27
  }
28
+
29
+ /**
30
+ * Pick the provider-native token for a terminal resume. `id` is Bivy's
31
+ * canonical conversation id; `ref` is the runtime's durable resume ref exposed
32
+ * as `SessionSummary.path` (a Codex rollout id, a Claude session id, etc.).
33
+ * They are often equal, but forks/imports deliberately give a conversation a
34
+ * new Bivy id while retaining a different provider ref.
35
+ */
36
+ export function nativeResumeRef(item) {
37
+ return item?.ref || item?.id;
38
+ }
@@ -497,14 +497,22 @@ function findClaudeTranscript(sessionId) {
497
497
  for (const project of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
498
498
  if (!project.isDirectory())
499
499
  continue;
500
- const projectRoot = fs.realpathSync(path.resolve(projectsRoot, project.name));
501
- if (!projectRoot.startsWith(`${projectsRoot}${path.sep}`))
502
- continue;
503
- const candidate = fs.realpathSync(path.resolve(projectRoot, fileName));
504
- if (!candidate.startsWith(`${projectRoot}${path.sep}`))
500
+ try {
501
+ const projectRoot = fs.realpathSync(path.resolve(projectsRoot, project.name));
502
+ if (!projectRoot.startsWith(`${projectsRoot}${path.sep}`))
503
+ continue;
504
+ // Most project directories do not contain this session. Resolve each
505
+ // candidate independently: a missing file in the first project must
506
+ // not abort the whole store scan and hide a transcript in a later one.
507
+ const candidate = fs.realpathSync(path.resolve(projectRoot, fileName));
508
+ if (!candidate.startsWith(`${projectRoot}${path.sep}`))
509
+ continue;
510
+ if (path.basename(candidate) === fileName)
511
+ return candidate;
512
+ }
513
+ catch {
505
514
  continue;
506
- if (path.basename(candidate) === fileName)
507
- return candidate;
515
+ }
508
516
  }
509
517
  }
510
518
  catch {
@@ -12,6 +12,25 @@ export function piAgentDir() {
12
12
  export function piCommand() {
13
13
  return process.env.BIVY_PI_COMMAND?.trim() || "pi";
14
14
  }
15
+ /** List sessions written by the operator's native `pi` TUI. Governed Pi chats
16
+ * use Bivy's own sessions directory, while `bivy run pi` intentionally keeps
17
+ * Pi's native auth/config untouched and therefore writes under
18
+ * PI_CODING_AGENT_DIR (normally ~/.pi/agent). Takeover discovery must inspect
19
+ * that native store rather than only the governed one. */
20
+ export async function listNativePiSessions() {
21
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
22
+ const sessions = await SessionManager.listAll(path.join(piAgentDir(), "sessions"));
23
+ return sessions.map((session) => ({
24
+ id: session.id,
25
+ path: session.path,
26
+ cwd: session.cwd,
27
+ name: session.name,
28
+ created: session.created,
29
+ modified: session.modified,
30
+ messageCount: session.messageCount,
31
+ firstMessage: session.firstMessage,
32
+ }));
33
+ }
15
34
  const PI_COMMAND_CACHE = new Map();
16
35
  export function piCommandAvailable() {
17
36
  const command = piCommand();
@@ -153,6 +153,7 @@ export const AGENT_PROFILES = {
153
153
  },
154
154
  gemini: {
155
155
  displayName: "Gemini CLI",
156
+ behaviors: { nativeSessions: "gemini" },
156
157
  command: "gemini",
157
158
  packageName: "@google/gemini-cli",
158
159
  supportTier: "supported",
@@ -185,7 +186,7 @@ export const AGENT_PROFILES = {
185
186
  // most recent or index number (e.g. --resume 5)`, per `gemini --help`; a
186
187
  // session UUID also works). `{sandbox}` re-derives --approval-mode from the
187
188
  // tier so a resumed turn stays as contained as a fresh one.
188
- resume: { template: ["-o", "json", "{sandbox}", "-r", "{id}", "-p"] },
189
+ resume: { template: ["-o", "json", "{sandbox}", "-r", "{id}", "-p"], historyLoader: "gemini" },
189
190
  // Gemini CLI speaks ACP (`--experimental-acp`), so it can be driven through the
190
191
  // governed ProtocolRuntime instead of the one-shot pipe — per-tool approvals +
191
192
  // streaming + resume. Opt in with BIVY_GEMINI_ACP=1 (or global BIVY_PREFER_ACP=1);
@@ -196,6 +197,7 @@ export const AGENT_PROFILES = {
196
197
  },
197
198
  qwen: {
198
199
  displayName: "Qwen Code",
200
+ behaviors: { nativeSessions: "qwen" },
199
201
  command: "qwen",
200
202
  packageName: "@qwen-code/qwen-code",
201
203
  supportTier: "supported",
@@ -225,7 +227,7 @@ export const AGENT_PROFILES = {
225
227
  },
226
228
  // Gemini-CLI fork: same `--resume <id>` headless resume form (Qwen Code docs,
227
229
  // "Headless Mode"). `{sandbox}` re-derives --approval-mode from the tier.
228
- resume: { template: ["--output-format", "json", "{sandbox}", "--resume", "{id}", "-p"] },
230
+ resume: { template: ["--output-format", "json", "{sandbox}", "--resume", "{id}", "-p"], historyLoader: "qwen" },
229
231
  // Qwen Code inherits Gemini CLI's ACP server (packages/cli/src/acp-integration),
230
232
  // so it can be driven through the governed ProtocolRuntime instead of the pipe —
231
233
  // per-tool approvals + streaming + resume. Newer builds graduated the flag to
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Gemini CLI / Qwen Code on-disk session readers. Both agents persist JSON or
4
+ // JSONL conversations, but currently use different directory layouts. Keep the
5
+ // parser tolerant: upstream has migrated Gemini from whole-file JSON to
6
+ // append-only JSONL, while Qwen stores one JSONL transcript per session.
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { hasLiveProcessForCwd } from "./native-process-scan.js";
11
+ function homeFor(agent) {
12
+ if (agent === "qwen") {
13
+ return process.env.QWEN_RUNTIME_DIR?.trim() || process.env.QWEN_HOME?.trim() || path.join(os.homedir(), ".qwen");
14
+ }
15
+ return process.env.GEMINI_CLI_HOME?.trim() || path.join(os.homedir(), ".gemini");
16
+ }
17
+ function textOf(value) {
18
+ if (typeof value === "string")
19
+ return value;
20
+ if (Array.isArray(value))
21
+ return value.map(textOf).filter(Boolean).join("\n");
22
+ if (!value || typeof value !== "object")
23
+ return "";
24
+ const rec = value;
25
+ if (typeof rec.text === "string")
26
+ return rec.text;
27
+ if (rec.parts)
28
+ return textOf(rec.parts);
29
+ return "";
30
+ }
31
+ function epoch(value) {
32
+ if (typeof value === "number" && Number.isFinite(value))
33
+ return value;
34
+ if (typeof value !== "string")
35
+ return undefined;
36
+ const parsed = Date.parse(value);
37
+ return Number.isFinite(parsed) ? parsed : undefined;
38
+ }
39
+ function projectRootNear(file) {
40
+ let dir = path.dirname(file);
41
+ for (let i = 0; i < 4; i++) {
42
+ try {
43
+ const root = fs.readFileSync(path.join(dir, ".project_root"), "utf8").trim();
44
+ if (root)
45
+ return root;
46
+ }
47
+ catch { /* continue upward */ }
48
+ const parent = path.dirname(dir);
49
+ if (parent === dir)
50
+ break;
51
+ dir = parent;
52
+ }
53
+ return undefined;
54
+ }
55
+ function parseRecords(file) {
56
+ let raw;
57
+ let stat;
58
+ try {
59
+ raw = fs.readFileSync(file, "utf8");
60
+ stat = fs.statSync(file);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ let records;
66
+ try {
67
+ const whole = JSON.parse(raw);
68
+ records = [whole, ...(Array.isArray(whole.messages) ? whole.messages : [])];
69
+ }
70
+ catch {
71
+ records = raw.split(/\r?\n/).filter((line) => line.trim()).flatMap((line) => {
72
+ try {
73
+ return [JSON.parse(line)];
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ });
79
+ }
80
+ let id;
81
+ let cwd = projectRootNear(file);
82
+ let createdAt;
83
+ let updatedAt;
84
+ const messages = new Map();
85
+ let sequence = 0;
86
+ const addMessage = (value) => {
87
+ if (!value || typeof value !== "object")
88
+ return;
89
+ const rec = value;
90
+ const type = rec.type ?? rec.role;
91
+ if (type !== "user" && type !== "gemini" && type !== "assistant" && type !== "model")
92
+ return;
93
+ const content = rec.content ?? rec.message;
94
+ const text = textOf(content).trim();
95
+ if (!text)
96
+ return;
97
+ const role = type === "user" ? "user" : "assistant";
98
+ const key = typeof rec.id === "string" ? rec.id : `message-${sequence++}`;
99
+ messages.set(key, { role, content: text, timestamp: epoch(rec.timestamp ?? rec.createdAt) });
100
+ };
101
+ for (const value of records) {
102
+ if (!value || typeof value !== "object")
103
+ continue;
104
+ const rec = value;
105
+ if (typeof rec.sessionId === "string")
106
+ id = rec.sessionId;
107
+ if (typeof rec.cwd === "string")
108
+ cwd = rec.cwd;
109
+ createdAt ??= epoch(rec.startTime ?? rec.createdAt ?? rec.timestamp);
110
+ updatedAt = epoch(rec.lastUpdated ?? rec.updatedAt ?? rec.timestamp) ?? updatedAt;
111
+ if (rec.$set && typeof rec.$set === "object") {
112
+ const set = rec.$set;
113
+ if (typeof set.sessionId === "string")
114
+ id = set.sessionId;
115
+ if (typeof set.cwd === "string")
116
+ cwd = set.cwd;
117
+ createdAt ??= epoch(set.startTime ?? set.createdAt);
118
+ updatedAt = epoch(set.lastUpdated ?? set.updatedAt) ?? updatedAt;
119
+ if (Array.isArray(set.messages))
120
+ for (const message of set.messages)
121
+ addMessage(message);
122
+ }
123
+ if (typeof rec.$rewindTo === "string") {
124
+ let remove = false;
125
+ for (const key of [...messages.keys()]) {
126
+ if (key === rec.$rewindTo)
127
+ remove = true;
128
+ if (remove)
129
+ messages.delete(key);
130
+ }
131
+ continue;
132
+ }
133
+ addMessage(rec);
134
+ }
135
+ // Qwen names transcripts by the full session id. Gemini's filename contains
136
+ // only a short id, so require its metadata instead of inventing an unusable ref.
137
+ if (!id && /^[0-9a-f-]{16,}\.jsonl$/i.test(path.basename(file)))
138
+ id = path.basename(file, ".jsonl");
139
+ if (!id)
140
+ return undefined;
141
+ const out = [...messages.values()];
142
+ const first = out.find((message) => message.role === "user");
143
+ return {
144
+ id,
145
+ file,
146
+ cwd,
147
+ createdAt: createdAt ?? (stat.birthtimeMs || stat.mtimeMs),
148
+ updatedAt: updatedAt ?? stat.mtimeMs,
149
+ firstMessage: typeof first?.content === "string" ? first.content.slice(0, 120) : undefined,
150
+ messages: out,
151
+ };
152
+ }
153
+ function sessionFiles(agent) {
154
+ const home = homeFor(agent);
155
+ const roots = agent === "qwen" ? [path.join(home, "projects")] : [path.join(home, "tmp")];
156
+ const out = [];
157
+ const walk = (dir, depth) => {
158
+ if (depth < 0)
159
+ return;
160
+ let entries;
161
+ try {
162
+ entries = fs.readdirSync(dir, { withFileTypes: true });
163
+ }
164
+ catch {
165
+ return;
166
+ }
167
+ for (const entry of entries) {
168
+ const full = path.join(dir, entry.name);
169
+ if (entry.isDirectory())
170
+ walk(full, depth - 1);
171
+ else if (entry.isFile() && /\.jsonl?$/.test(entry.name) && !entry.name.endsWith(".ledger.jsonl")) {
172
+ const parent = path.basename(path.dirname(full));
173
+ if (parent === "chats" || path.basename(path.dirname(path.dirname(full))) === "chats")
174
+ out.push(full);
175
+ }
176
+ }
177
+ };
178
+ for (const root of roots)
179
+ walk(root, 4);
180
+ return out;
181
+ }
182
+ export function listGeminiFamilySessions(agent) {
183
+ return sessionFiles(agent)
184
+ .map(parseRecords)
185
+ .filter((session) => Boolean(session))
186
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
187
+ }
188
+ export function loadGeminiFamilyTranscript(agent, sessionId) {
189
+ return listGeminiFamilySessions(agent).find((session) => session.id === sessionId)?.messages ?? [];
190
+ }
191
+ export function discoverGeminiFamilySessionForCwd(agent, cwd, since) {
192
+ const target = path.resolve(cwd);
193
+ const earliest = since - 10_000;
194
+ return listGeminiFamilySessions(agent)
195
+ .filter((session) => session.cwd && path.resolve(session.cwd) === target && (session.createdAt ?? 0) >= earliest)
196
+ .sort((a, b) => Math.abs((a.createdAt ?? 0) - since) - Math.abs((b.createdAt ?? 0) - since))[0];
197
+ }
198
+ export function discoverNativeGeminiFamilySessions(agent) {
199
+ return listGeminiFamilySessions(agent).map((session) => ({
200
+ runtimeId: agent,
201
+ ref: session.id,
202
+ file: session.file,
203
+ cwd: session.cwd,
204
+ updatedAt: session.updatedAt ?? session.createdAt,
205
+ title: session.firstMessage,
206
+ active: Boolean(session.cwd) && hasLiveProcessForCwd(session.cwd, agent === "gemini" ? ["gemini"] : ["qwen", "qwen-code"]),
207
+ resumable: true,
208
+ }));
209
+ }
@@ -13,6 +13,7 @@ import { invalidatePiCommandProbe, piAgentDir, piCommandAvailable, piIntegration
13
13
  import { deleteCodexSession, loadCodexTranscript } from "./codex-sessions.js";
14
14
  import { deleteOpenCodeSession, exportOpenCodeSession, importOpenCodeSession, loadOpenCodeTranscript, writeOpenCodeHistory } from "./opencode-sessions.js";
15
15
  import { discoverNativeGrokSessions, listGrokSessions, loadGrokTranscript } from "./grok-sessions.js";
16
+ import { discoverNativeGeminiFamilySessions, listGeminiFamilySessions, loadGeminiFamilyTranscript } from "./gemini-sessions.js";
16
17
  import { createCredentialStore } from "./credentials.js";
17
18
  import { AgentRegistry } from "../agents/registry.js";
18
19
  import { applyCertification } from "../certification/index.js";
@@ -1196,7 +1197,11 @@ function makeCliRuntime(id, options, spec) {
1196
1197
  : resumeTemplate
1197
1198
  ? {
1198
1199
  resumable: true,
1199
- loadHistory: spec.resume?.historyLoader === "grok" ? loadGrokTranscript : undefined,
1200
+ loadHistory: spec.resume?.historyLoader === "grok"
1201
+ ? loadGrokTranscript
1202
+ : spec.resume?.historyLoader === "gemini" || spec.resume?.historyLoader === "qwen"
1203
+ ? (sessionId) => loadGeminiFamilyTranscript(spec.resume.historyLoader, sessionId)
1204
+ : undefined,
1200
1205
  // `{sandbox}` expands to that agent's native containment flags for
1201
1206
  // the tier (e.g. Gemini/Qwen's `--approval-mode <mode>`) — a whole
1202
1207
  // token, not a string substitution, since it can be multiple argv
@@ -1213,24 +1218,36 @@ function makeCliRuntime(id, options, spec) {
1213
1218
  : [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
1214
1219
  }
1215
1220
  : {};
1216
- // Grok-specific: on-disk session enumeration + interactive TUI hand-off so
1217
- // `bivy run grok` sessions persist after the PTY exits and can be taken
1218
- // over as chat or reopened in the native TUI.
1219
- const nativeSessionOpts = behaviors?.nativeSessions === "grok"
1221
+ // Profile-selected native session enumeration + interactive TUI hand-off.
1222
+ // Store readers keep terminal-originated sessions resumable and let the
1223
+ // generic ProcessRuntime preload their transcript during chat takeover.
1224
+ const nativeSessionBehavior = behaviors?.nativeSessions;
1225
+ const nativeSessionOpts = nativeSessionBehavior
1220
1226
  ? {
1221
1227
  sessionDiscovery: true,
1222
- listDiskSessions: () => listGrokSessions().map((s) => ({
1223
- id: s.id,
1224
- path: s.dir,
1225
- cwd: s.cwd,
1226
- name: s.name || s.firstMessage,
1227
- created: s.createdAt ? new Date(s.createdAt).toISOString() : undefined,
1228
- modified: s.updatedAt ? new Date(s.updatedAt).toISOString() : undefined,
1229
- firstMessage: s.firstMessage,
1230
- })),
1231
- interactiveTui: ({ sessionRef, env }) => sessionRef ? { command: "grok", args: ["--resume", sessionRef], env } : null,
1232
- // discoverNativeSessions is on AgentRuntime; ProcessRuntime doesn't
1233
- // expose it as an option — wire via a thin subclass below when needed.
1228
+ listDiskSessions: () => {
1229
+ if (nativeSessionBehavior === "grok") {
1230
+ return listGrokSessions().map((s) => ({
1231
+ id: s.id,
1232
+ path: s.dir,
1233
+ cwd: s.cwd,
1234
+ name: s.name || s.firstMessage,
1235
+ created: s.createdAt ? new Date(s.createdAt).toISOString() : undefined,
1236
+ modified: s.updatedAt ? new Date(s.updatedAt).toISOString() : undefined,
1237
+ firstMessage: s.firstMessage,
1238
+ }));
1239
+ }
1240
+ return listGeminiFamilySessions(nativeSessionBehavior).map((s) => ({
1241
+ id: s.id,
1242
+ path: s.id,
1243
+ cwd: s.cwd,
1244
+ name: s.firstMessage,
1245
+ created: s.createdAt ? new Date(s.createdAt).toISOString() : undefined,
1246
+ modified: s.updatedAt ? new Date(s.updatedAt).toISOString() : undefined,
1247
+ firstMessage: s.firstMessage,
1248
+ }));
1249
+ },
1250
+ interactiveTui: ({ sessionRef, env }) => sessionRef ? { command: spec.command, args: ["--resume", sessionRef], env } : null,
1234
1251
  }
1235
1252
  : {};
1236
1253
  const runtime = new ProcessRuntime({
@@ -1250,10 +1267,12 @@ function makeCliRuntime(id, options, spec) {
1250
1267
  ...resumeOpts,
1251
1268
  ...nativeSessionOpts,
1252
1269
  });
1253
- if (behaviors?.nativeSessions === "grok") {
1270
+ if (nativeSessionBehavior) {
1254
1271
  // Issue #156 discovery surface — ProcessRuntime has no options hook for
1255
- // this; attach it so collectDiscoveredSessions picks Grok sessions up.
1256
- runtime.discoverNativeSessions = () => discoverNativeGrokSessions();
1272
+ // this; attach the profile's native store reader to the generic runtime.
1273
+ runtime.discoverNativeSessions = () => nativeSessionBehavior === "grok"
1274
+ ? discoverNativeGrokSessions()
1275
+ : discoverNativeGeminiFamilySessions(nativeSessionBehavior);
1257
1276
  }
1258
1277
  return runtime;
1259
1278
  }
package/dist/server.js CHANGED
@@ -48,6 +48,7 @@ import { isNativeOAuthProvider, loginModelOAuth } from "./runtime/oauth/model-oa
48
48
  import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
49
49
  import { listCodexSessions, loadCodexTranscript } from "./runtime/codex-sessions.js";
50
50
  import { dedupeSessionSummaries } from "./session-identity.js";
51
+ import { listNativePiSessions } from "./agents/pi/integration.js";
51
52
  import { deriveSessionState } from "./session/session-state.js";
52
53
  import { resolveStreamingBehavior } from "./session/record.js";
53
54
  import { createSessionEngine } from "./session/engine.js";
@@ -8465,7 +8466,19 @@ const runTerms = createRunTerminals({
8465
8466
  listAllSessions,
8466
8467
  listProvidersUnified,
8467
8468
  pushModelAuthToControlPlane: () => pushModelAuthToControlPlane(),
8468
- listPiSessions: () => runtimeHost.listSessions(getRuntime("pi")),
8469
+ listPiSessions: async () => {
8470
+ // `bivy run pi` is an agent-owned native TUI and writes to Pi's own store;
8471
+ // governed Pi chats write to Bivy's isolated store. Search both so a native
8472
+ // terminal can always be correlated and taken over after its first turn.
8473
+ const [governed, native] = await Promise.all([
8474
+ runtimeHost.listSessions(getRuntime("pi")).catch(() => []),
8475
+ listNativePiSessions().catch(() => []),
8476
+ ]);
8477
+ const byRef = new Map();
8478
+ for (const session of [...governed, ...native])
8479
+ byRef.set(session.path || session.id, session);
8480
+ return [...byRef.values()];
8481
+ },
8469
8482
  resolveAuthOwner: (agent) => {
8470
8483
  const integrationId = agent ? canonicalAgentId(agent) : undefined;
8471
8484
  return listRuntimes(agent).find((a) => a.id === integrationId)?.authOwner ?? "agent";
@@ -10094,7 +10107,11 @@ app.post("/api/session", async (req, res, next) => {
10094
10107
  res.json({ id: session.id, workspace: session.workspace, source: session.source, branch: session.worktree?.branch, prUrl: session.prUrl, sessionFile: session.sessionFile, name: session.session.getName(), runtimeId: session.runtimeId, agentName: getRuntime(session.runtimeId).displayName, model: publicModel(session.session.getCurrentModel(), session.session.getCurrentModel()), sessionState: sessionState(session) });
10095
10108
  }
10096
10109
  catch (error) {
10097
- res.status(400).json({ error: actionableAgentError(agentFrom(req.body ?? {}) ?? defaultRuntimeId, error) });
10110
+ // Creating an empty session does not contact the model provider. Failures
10111
+ // here come from workspace/repository/runtime setup (for example Git remote
10112
+ // authentication), so classifying a generic "authentication failed" as a
10113
+ // Claude/Codex login problem sends the user to the wrong sign-in flow.
10114
+ res.status(400).json({ error: humanizeAgentError(error instanceof Error ? error.message : String(error)) });
10098
10115
  }
10099
10116
  });
10100
10117
  app.get("/api/github/issues", async (_req, res, next) => {
@@ -18,6 +18,7 @@ import { provisionAgentRun } from "../runtime/credential-provisioning.js";
18
18
  import { ingestAgentCredentials } from "../runtime/credential-ingest.js";
19
19
  import { discoverCodexSessionForCwd } from "../runtime/codex-sessions.js";
20
20
  import { discoverGrokSessionForCwd } from "../runtime/grok-sessions.js";
21
+ import { discoverGeminiFamilySessionForCwd } from "../runtime/gemini-sessions.js";
21
22
  import { discoverOpenCodeSessionForCwd } from "../runtime/opencode-sessions.js";
22
23
  import { discoverPiSessionForCwd } from "../runtime/pi-session-discovery.js";
23
24
  import { listMultiplexerSessions, attachCommand } from "../multiplexer.js";
@@ -30,6 +31,7 @@ const TAKEOVER_RUNTIME_BY_AGENT = {
30
31
  // Pinned at launch (`gemini --session-id <uuid>`); the gemini runtime's resume
31
32
  // template (`-r {id}`) reopens it as a governed chat.
32
33
  gemini: "gemini",
34
+ qwen: "qwen",
33
35
  // Discovered at exit from OpenCode's own store (discoverOpenCodeSessionForCwd);
34
36
  // the opencode runtime's resume template (`run -s {id}`) continues it.
35
37
  opencode: "opencode",
@@ -40,6 +42,7 @@ const RESUME_CLI_BY_AGENT = {
40
42
  codex: (id) => `codex resume ${id}`,
41
43
  grok: (id) => `grok --resume ${id}`,
42
44
  gemini: (id) => `gemini --resume ${id}`,
45
+ qwen: (id) => `qwen --resume ${id}`,
43
46
  opencode: (id) => `opencode --session ${id}`,
44
47
  };
45
48
  // When no resumable session id can be found, why — and what to do about it.
@@ -47,6 +50,8 @@ const TAKEOVER_EMPTY_HINT_BY_AGENT = {
47
50
  codex: "Codex writes its session only after the first message. Send one message in the terminal, then continue as chat.",
48
51
  pi: "Pi assigns its session once the conversation starts. Send one message in the terminal, then continue as chat.",
49
52
  grok: "Grok writes its session once the conversation starts. Send one message in the terminal, then continue as chat.",
53
+ gemini: "Gemini records its session after the conversation starts. Send one message in the terminal, then continue as chat.",
54
+ qwen: "Qwen records its session after the conversation starts. Send one message in the terminal, then continue as chat.",
50
55
  opencode: "OpenCode records its session as the TUI starts; if it isn't found yet, give it a moment and try again.",
51
56
  };
52
57
  /** Synthesize a default session name for a plain `bivy run` from agent + workspace. */
@@ -59,6 +64,8 @@ const ACTIVITY_PING_INTERVAL = 2000;
59
64
  const IDLE_NOTIFY_INTERVAL = Number(process.env.BIVY_RUN_IDLE_NOTIFY_MS) || 30_000;
60
65
  const BELL_QUIET_INPUT_MS = Number(process.env.BIVY_TERM_BELL_QUIET_MS) || 8_000;
61
66
  const BELL_NOTIFY_COOLDOWN_MS = Number(process.env.BIVY_TERM_BELL_COOLDOWN_MS) || 45_000;
67
+ const TAKEOVER_DISCOVERY_ATTEMPTS = 10;
68
+ const TAKEOVER_DISCOVERY_DELAY_MS = 200;
62
69
  export function createRunTerminals(deps) {
63
70
  const { terminals } = deps;
64
71
  // Native agents' on-disk session, located by cwd + terminal start time for
@@ -70,6 +77,8 @@ export function createRunTerminals(deps) {
70
77
  return match?.path || match?.id;
71
78
  },
72
79
  grok: (cwd, since) => discoverGrokSessionForCwd(cwd, since)?.id,
80
+ gemini: (cwd, since) => discoverGeminiFamilySessionForCwd("gemini", cwd, since)?.id,
81
+ qwen: (cwd, since) => discoverGeminiFamilySessionForCwd("qwen", cwd, since)?.id,
73
82
  opencode: (cwd, since) => discoverOpenCodeSessionForCwd(cwd, since)?.id,
74
83
  };
75
84
  const runTerminals = new Set();
@@ -329,6 +338,20 @@ export function createRunTerminals(deps) {
329
338
  return undefined;
330
339
  }
331
340
  }
341
+ async function discoverForTakeover(agent, workspace, createdAt) {
342
+ const discover = SESSION_DISCOVERY_BY_AGENT[agent];
343
+ if (!discover)
344
+ return undefined;
345
+ for (let attempt = 0; attempt < TAKEOVER_DISCOVERY_ATTEMPTS; attempt++) {
346
+ const ref = await discover(workspace, createdAt);
347
+ if (ref)
348
+ return ref;
349
+ if (attempt + 1 < TAKEOVER_DISCOVERY_ATTEMPTS) {
350
+ await new Promise((resolve) => setTimeout(resolve, TAKEOVER_DISCOVERY_DELAY_MS));
351
+ }
352
+ }
353
+ return undefined;
354
+ }
332
355
  async function takeoverRunTerminal(opts) {
333
356
  const runs = terminals.list((m) => m.kind === "run").filter((t) => runTerminals.has(t.id));
334
357
  const entry = opts.termId
@@ -342,20 +365,25 @@ export function createRunTerminals(deps) {
342
365
  const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[agent];
343
366
  if (!runtimeId)
344
367
  return { ok: false, status: 409, error: `"Continue as chat" isn't supported for "${agent || "this agent"}" yet.` };
345
- const discovered = entry.meta.sessionId ? undefined : await SESSION_DISCOVERY_BY_AGENT[agent]?.(entry.workspace, entry.createdAt);
368
+ // Several CLIs assign/persist their session id asynchronously after the
369
+ // first turn. Give their store a short bounded window to settle instead of
370
+ // making an immediate takeover spuriously fail.
371
+ const discovered = entry.meta.sessionId ? undefined : await discoverForTakeover(agent, entry.workspace, entry.createdAt);
346
372
  const pinned = entry.meta.sessionId ?? discovered;
347
373
  if (!pinned) {
348
374
  const hint = TAKEOVER_EMPTY_HINT_BY_AGENT[agent] ?? `Start it via the shim (or \`bivy run\`), or continue it in a terminal.`;
349
375
  return { ok: false, status: 409, error: `Couldn't find a ${agent || "session"} session to continue yet. ${hint}` };
350
376
  }
351
377
  const workspace = entry.workspace;
352
- // 1. Stop the native TUI (the PTY this run-terminal owns is the kill target).
378
+ // Prepare and validate the governed resume BEFORE stopping the native TUI.
379
+ // Runtime openSession paths only read/bind durable state; they do not start a
380
+ // second agent process until the next prompt. If preparation fails, the
381
+ // terminal remains live and the user loses nothing.
382
+ const record = await deps.createSession(workspace, pinned, { runtimeId, makeActive: true, source: "takeover" });
353
383
  terminals.close(entry.id);
354
384
  runTerminals.delete(entry.id);
355
385
  runViewers.delete(entry.id);
356
386
  deps.broadcast({ type: "terminal.closed", termId: entry.id });
357
- // 2. Reopen the session as a chat on its runtime.
358
- const record = await deps.createSession(workspace, pinned, { runtimeId, makeActive: true, source: "takeover" });
359
387
  const resumeCommand = RESUME_CLI_BY_AGENT[agent]?.(pinned);
360
388
  return { ok: true, sessionId: record.id, runtimeId, resumeCommand };
361
389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.1-staging.3",
3
+ "version": "0.16.1-staging.5",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",