@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 +18 -11
- package/bin/install-kind.mjs +20 -0
- package/bin/service-state.mjs +12 -0
- package/bin/sessions-list.mjs +11 -0
- package/dist/agents/claude-code/runtime.js +15 -7
- package/dist/agents/pi/integration.js +19 -0
- package/dist/agents/profiles.js +4 -2
- package/dist/runtime/gemini-sessions.js +209 -0
- package/dist/runtime/index.js +39 -20
- package/dist/server.js +19 -2
- package/dist/session/run-terminal.js +32 -4
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/bin/sessions-list.mjs
CHANGED
|
@@ -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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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();
|
package/dist/agents/profiles.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -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"
|
|
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
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
const
|
|
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: () =>
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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 (
|
|
1270
|
+
if (nativeSessionBehavior) {
|
|
1254
1271
|
// Issue #156 discovery surface — ProcessRuntime has no options hook for
|
|
1255
|
-
// this; attach
|
|
1256
|
-
runtime.discoverNativeSessions = () =>
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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