@bivy/bivy 0.16.1-staging.4 → 0.16.1-staging.6

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,7 +34,7 @@ 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";
@@ -1178,6 +1178,7 @@ const AGENT_RESUME_ARGS = {
1178
1178
  codex: (id) => ["resume", id],
1179
1179
  grok: (id) => ["--resume", id],
1180
1180
  gemini: (id) => ["--resume", id],
1181
+ qwen: (id) => ["--resume", id],
1181
1182
  // `opencode -s, --session session id to continue` (TUI), per `opencode --help`.
1182
1183
  opencode: (id) => ["--session", id],
1183
1184
  };
@@ -2428,7 +2429,11 @@ async function resumeSessionItem(item, config, token) {
2428
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'.`));
2429
2430
  return;
2430
2431
  }
2431
- 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);
2432
2437
  const runArgs = [agentId, ...resumeArgs];
2433
2438
  if (item.workspace) runArgs.push("--workspace", item.workspace); // native resume finds the session by its original cwd
2434
2439
  console.log(c.dim(`Resuming ${c.cyan(item.name)} with ${agentId} ${resumeArgs.join(" ")}…`));
@@ -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
+ }
@@ -4,7 +4,7 @@ import { defineAgentIntegration } from "../definition.js";
4
4
  import { withExactCapabilitySurface } from "../../runtime/types.js";
5
5
  import { createCredentialStore } from "../../runtime/credentials.js";
6
6
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
7
- export const CLAUDE_TESTED_VERSION = "0.3.251";
7
+ export const CLAUDE_TESTED_VERSION = "0.3.252";
8
8
  const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
9
9
  toolInterception: true,
10
10
  modelSelection: true,
@@ -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
@@ -3,7 +3,7 @@
3
3
  export const CERTIFICATION_MATRIX = {
4
4
  schemaVersion: 1,
5
5
  agents: [
6
- { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.251", capabilities: ["toolInterception", "modelSelection", "resume"] },
6
+ { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.252", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
7
  { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.151.0", capabilities: ["toolInterception", "modelSelection", "resume"] },
8
8
  { id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.84.4", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
9
  { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.25", capabilities: ["toolInterception", "modelSelection", "resume"] },
@@ -90,6 +90,12 @@ class TurnAccumulator {
90
90
  textFlushed = "";
91
91
  out = [];
92
92
  details = new Map();
93
+ // Not every CLI includes a stable id on both halves of a tool exchange.
94
+ // Keep a small FIFO of open calls so the universal parser can still pair an
95
+ // anonymous result with its call instead of creating a second, forever-running
96
+ // card in the transcript.
97
+ openToolIds = [];
98
+ anonymousToolId = 0;
93
99
  flushPendingText() {
94
100
  const pending = this.text.slice(this.textFlushed.length);
95
101
  this.textFlushed = this.text;
@@ -131,25 +137,35 @@ class TurnAccumulator {
131
137
  events.push({ type: "message_update", message: { role: "assistant", content: this.text } });
132
138
  }
133
139
  addToolUse(id, name, input, events) {
134
- traceToolPayload({ phase: "call", context: this.toolContext, name, callId: id, payload: input });
140
+ const callId = id || `cli-tool-${++this.anonymousToolId}`;
141
+ this.openToolIds.push(callId);
142
+ traceToolPayload({ phase: "call", context: this.toolContext, name, callId, payload: input });
135
143
  // Attach a normalized ToolCallDetail (display-only) so the PWA renders this
136
144
  // call the same way it renders every other agent's equivalent call. Absent
137
145
  // when unrecognized — the block stays opaque and renders as before.
138
146
  const detail = mapToolCall(name, input, this.toolContext);
139
- if (detail && id)
140
- this.details.set(id, detail);
147
+ if (detail)
148
+ this.details.set(callId, detail);
141
149
  this.flushPendingText();
142
- this.content.push({ type: "tool_use", id, name, input: input ?? {}, ...(detail ? { detail } : {}) });
143
- events.push({ type: "tool_call", toolName: name, input, toolCallId: id, ...(detail ? { detail } : {}) });
150
+ this.content.push({ type: "tool_use", id: callId, name, input: input ?? {}, ...(detail ? { detail } : {}) });
151
+ events.push({ type: "tool_call", toolName: name, input, toolCallId: callId, ...(detail ? { detail } : {}) });
144
152
  }
145
153
  addToolResult(toolUseId, name, content, events, isError = false) {
146
- traceToolPayload({ phase: "result", context: this.toolContext, name, callId: toolUseId, payload: content });
147
- const prior = this.details.get(toolUseId);
154
+ // Prefer the explicit id. Otherwise close the oldest open call, which is
155
+ // the only generally safe correlation available from an id-less stream.
156
+ const callId = toolUseId || this.openToolIds.shift() || `cli-tool-result-${++this.anonymousToolId}`;
157
+ if (toolUseId) {
158
+ const index = this.openToolIds.indexOf(toolUseId);
159
+ if (index >= 0)
160
+ this.openToolIds.splice(index, 1);
161
+ }
162
+ traceToolPayload({ phase: "result", context: this.toolContext, name, callId, payload: content });
163
+ const prior = this.details.get(callId);
148
164
  const detail = prior ? { ...prior, result: mapToolResult(content, isError) } : undefined;
149
165
  if (detail)
150
- this.details.set(toolUseId, detail);
151
- this.toolResults.push({ type: "tool_result", tool_use_id: toolUseId, content: content ?? "", ...(detail ? { detail } : {}) });
152
- events.push({ type: "tool_result", toolName: name, result: { toolCallId: toolUseId, content }, ...(detail ? { detail } : {}) });
166
+ this.details.set(callId, detail);
167
+ this.toolResults.push({ type: "tool_result", tool_use_id: callId, content: content ?? "", ...(detail ? { detail } : {}) });
168
+ events.push({ type: "tool_result", toolName: name, result: { toolCallId: callId, content }, ...(detail ? { detail } : {}) });
153
169
  }
154
170
  /** Finalize the turn: emit message_end/turn_end/agent_end and record history. */
155
171
  finish(events) {
@@ -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
  }
@@ -193,6 +193,25 @@ class ProtocolSession {
193
193
  // toolCallId -> the node's normalized classification, so a later tool.result
194
194
  // (or tool.update) can attach/refresh `detail` on the already-pushed block.
195
195
  toolDetailsByCallId = new Map();
196
+ openToolIds = [];
197
+ anonymousToolId = 0;
198
+ /** Protocol is intentionally agent-neutral: if an upstream omits ids, give
199
+ * the exchange a local identity so live and persisted views can still pair it. */
200
+ resolveToolId(raw, result = false) {
201
+ if (raw) {
202
+ if (result) {
203
+ const index = this.openToolIds.indexOf(raw);
204
+ if (index >= 0)
205
+ this.openToolIds.splice(index, 1);
206
+ }
207
+ return raw;
208
+ }
209
+ if (result)
210
+ return this.openToolIds.shift() || `protocol-tool-result-${++this.anonymousToolId}`;
211
+ const id = `protocol-tool-${++this.anonymousToolId}`;
212
+ this.openToolIds.push(id);
213
+ return id;
214
+ }
196
215
  constructor(runtimeOptions, cwd, capabilitiesRef, toolInterceptor,
197
216
  // When resuming, the agent's own session ref (e.g. a Codex thread id). Passed
198
217
  // back to the shim via the generic session.resume primitive so it reconnects
@@ -686,6 +705,7 @@ class ProtocolSession {
686
705
  this.turnTextFlushed = "";
687
706
  this.turnToolResults = [];
688
707
  this.toolDetailsByCallId.clear();
708
+ this.openToolIds = [];
689
709
  this.emit({ type: "agent_end" });
690
710
  return;
691
711
  }
@@ -696,12 +716,13 @@ class ProtocolSession {
696
716
  this.turnTextFlushed = "";
697
717
  this.turnToolResults = [];
698
718
  this.toolDetailsByCallId.clear();
719
+ this.openToolIds = [];
699
720
  this.emit({ type: "session.error", error: String(msg.error || "Protocol agent error") });
700
721
  this.emit({ type: "agent_end" });
701
722
  return;
702
723
  }
703
724
  if (type === "tool.call" || type === "tool.observe") {
704
- const toolCallId = String(msg.toolCallId || msg.id || "");
725
+ const toolCallId = this.resolveToolId(String(msg.toolCallId || msg.id || ""));
705
726
  const toolName = String(msg.name || "tool");
706
727
  const detail = mapToolCall(toolName, msg.input, { provider: this.runtimeOptions.id || "acp", protocol: "protocol" });
707
728
  if (detail)
@@ -763,7 +784,7 @@ class ProtocolSession {
763
784
  return;
764
785
  }
765
786
  if (type === "tool.result") {
766
- const toolCallId = String(msg.toolCallId || msg.tool_use_id || msg.id || "");
787
+ const toolCallId = this.resolveToolId(String(msg.toolCallId || msg.tool_use_id || msg.id || ""), true);
767
788
  const result = msg.result ?? msg.output ?? msg.content ?? msg.text ?? msg.summary ?? "";
768
789
  const isError = Boolean(msg.isError || msg.is_error);
769
790
  this.turnToolResults.push({
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.4",
3
+ "version": "0.16.1-staging.6",
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.",
@@ -50,7 +50,7 @@
50
50
  "zod": "^4.0.0"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "0.3.251",
53
+ "@anthropic-ai/claude-agent-sdk": "0.3.252",
54
54
  "@earendil-works/pi-ai": "0.84.4",
55
55
  "@earendil-works/pi-coding-agent": "0.84.4",
56
56
  "node-pty": "^1.1.0"