@bivy/bivy 0.10.1-staging.151 → 0.10.1-staging.152
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/agent-manifest.json +2 -1
- package/bin/bivy.mjs +4 -0
- package/dist/runtime/grok-sessions.js +283 -0
- package/dist/runtime/index.js +76 -5
- package/dist/runtime/process.js +42 -0
- package/dist/server.js +77 -1
- package/dist/terminal.js +5 -1
- package/package.json +1 -1
package/bin/agent-manifest.json
CHANGED
package/bin/bivy.mjs
CHANGED
|
@@ -987,6 +987,9 @@ const AGENT_HEADLESS_FLAGS = {
|
|
|
987
987
|
// appear here; others simply run without a pinned id.
|
|
988
988
|
const AGENT_SESSION_ID_FLAG = {
|
|
989
989
|
claude: "--session-id", // `claude --session-id <uuid>` (must be a valid UUID)
|
|
990
|
+
// Official Grok CLI: `grok --session-id <uuid>` pins a new session UUID under
|
|
991
|
+
// ~/.grok/sessions/<cwd>/<uuid>/ so takeover / resume has a known target.
|
|
992
|
+
grok: "--session-id",
|
|
990
993
|
};
|
|
991
994
|
|
|
992
995
|
// Args that mean the caller already chose a session (pin or resume), so we must
|
|
@@ -1001,6 +1004,7 @@ const SESSION_ID_CONFLICTS = ["--session-id", "--resume", "-r", "-c", "--continu
|
|
|
1001
1004
|
const AGENT_RESUME_ARGS = {
|
|
1002
1005
|
claude: (id) => ["--resume", id],
|
|
1003
1006
|
codex: (id) => ["resume", id],
|
|
1007
|
+
grok: (id) => ["--resume", id],
|
|
1004
1008
|
};
|
|
1005
1009
|
function agentResumeArgs(agentId, sessionRef) {
|
|
1006
1010
|
const fn = AGENT_RESUME_ARGS[(agentId || "").toLowerCase()];
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Grok CLI on-disk session reader — the read side of adopting a Grok session
|
|
5
|
+
// started outside governed chat (e.g. `bivy run grok` or a bare `grok` TUI).
|
|
6
|
+
//
|
|
7
|
+
// Layout (official Grok CLI 1.x):
|
|
8
|
+
// $GROK_HOME/sessions/<url-encoded-cwd>/<session-uuid>/
|
|
9
|
+
// summary.json — id, cwd, title, timestamps, model
|
|
10
|
+
// chat_history.jsonl — user / assistant / system / tool_result lines
|
|
11
|
+
//
|
|
12
|
+
// GROK_HOME defaults to ~/.grok (see grok-auth.ts's resolveGrokHome). Every read
|
|
13
|
+
// is best-effort: a missing/malformed store never throws out of the list path.
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { resolveGrokHome } from "./grok-auth.js";
|
|
17
|
+
import { hasLiveProcessForCwd } from "./native-process-scan.js";
|
|
18
|
+
const GROK_BIN_NAMES = ["grok"];
|
|
19
|
+
export function grokSessionsRoot() {
|
|
20
|
+
return path.join(resolveGrokHome(), "sessions");
|
|
21
|
+
}
|
|
22
|
+
function toEpoch(value) {
|
|
23
|
+
if (value == null)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
26
|
+
return value;
|
|
27
|
+
const ms = new Date(value).getTime();
|
|
28
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
29
|
+
}
|
|
30
|
+
function readSummary(dir) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, "summary.json"), "utf8"));
|
|
33
|
+
const info = (raw.info && typeof raw.info === "object" ? raw.info : {});
|
|
34
|
+
const id = typeof info.id === "string" ? info.id : path.basename(dir);
|
|
35
|
+
const cwd = typeof info.cwd === "string" ? info.cwd : undefined;
|
|
36
|
+
const name = (typeof raw.generated_title === "string" && raw.generated_title.trim()) ||
|
|
37
|
+
(typeof raw.session_summary === "string" && raw.session_summary.trim()) ||
|
|
38
|
+
undefined;
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
dir,
|
|
42
|
+
cwd,
|
|
43
|
+
createdAt: toEpoch(raw.created_at),
|
|
44
|
+
updatedAt: toEpoch(raw.last_active_at ?? raw.updated_at),
|
|
45
|
+
name,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Pull plain text out of a Grok content field (string or content-block array). */
|
|
53
|
+
function contentText(content) {
|
|
54
|
+
if (typeof content === "string")
|
|
55
|
+
return content;
|
|
56
|
+
if (!Array.isArray(content))
|
|
57
|
+
return "";
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const block of content) {
|
|
60
|
+
if (!block || typeof block !== "object")
|
|
61
|
+
continue;
|
|
62
|
+
const b = block;
|
|
63
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
64
|
+
parts.push(b.text);
|
|
65
|
+
else if (typeof b.text === "string")
|
|
66
|
+
parts.push(b.text);
|
|
67
|
+
}
|
|
68
|
+
return parts.join("\n");
|
|
69
|
+
}
|
|
70
|
+
/** First user-visible prompt that isn't a system/meta wrapper. */
|
|
71
|
+
function firstUserMessage(dir) {
|
|
72
|
+
const file = path.join(dir, "chat_history.jsonl");
|
|
73
|
+
let fh;
|
|
74
|
+
try {
|
|
75
|
+
fh = fs.openSync(file, "r");
|
|
76
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
77
|
+
const n = fs.readSync(fh, buf, 0, buf.length, 0);
|
|
78
|
+
const chunk = buf.subarray(0, n).toString("utf8");
|
|
79
|
+
for (const line of chunk.split("\n")) {
|
|
80
|
+
if (!line.trim())
|
|
81
|
+
continue;
|
|
82
|
+
let rec;
|
|
83
|
+
try {
|
|
84
|
+
rec = JSON.parse(line);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (rec.type !== "user")
|
|
90
|
+
continue;
|
|
91
|
+
const text = contentText(rec.content).trim();
|
|
92
|
+
if (!text)
|
|
93
|
+
continue;
|
|
94
|
+
// Skip the CLI's environment / system-reminder scaffolding — the first
|
|
95
|
+
// real user_query (or a short free-form prompt) is the list label.
|
|
96
|
+
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim();
|
|
97
|
+
const label = (query || text).replace(/\s+/g, " ").trim();
|
|
98
|
+
if (!label)
|
|
99
|
+
continue;
|
|
100
|
+
// Skip pure meta blobs that never carry a user question.
|
|
101
|
+
if (label.startsWith("<user_info>") || label.startsWith("<system-reminder>") || label.startsWith("<git_status>")) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
return label.length > 120 ? `${label.slice(0, 119)}…` : label;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (fh !== undefined)
|
|
112
|
+
try {
|
|
113
|
+
fs.closeSync(fh);
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Enumerate Grok sessions under $GROK_HOME/sessions. Best-effort: a missing or
|
|
121
|
+
* unreadable store returns []. Sorted newest-activity-first.
|
|
122
|
+
*/
|
|
123
|
+
export function listGrokSessions() {
|
|
124
|
+
const root = grokSessionsRoot();
|
|
125
|
+
let cwdEntries;
|
|
126
|
+
try {
|
|
127
|
+
cwdEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const cwdEntry of cwdEntries) {
|
|
134
|
+
if (!cwdEntry.isDirectory())
|
|
135
|
+
continue;
|
|
136
|
+
// Skip the search index / non-session dirs.
|
|
137
|
+
if (cwdEntry.name === "session_search.sqlite" || cwdEntry.name.endsWith(".sqlite"))
|
|
138
|
+
continue;
|
|
139
|
+
const cwdDir = path.join(root, cwdEntry.name);
|
|
140
|
+
let sessionEntries;
|
|
141
|
+
try {
|
|
142
|
+
sessionEntries = fs.readdirSync(cwdDir, { withFileTypes: true });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const sessionEntry of sessionEntries) {
|
|
148
|
+
if (!sessionEntry.isDirectory())
|
|
149
|
+
continue;
|
|
150
|
+
const dir = path.join(cwdDir, sessionEntry.name);
|
|
151
|
+
const summary = readSummary(dir);
|
|
152
|
+
if (!summary?.id)
|
|
153
|
+
continue;
|
|
154
|
+
// Prefer the cwd encoded in the parent dir name when summary lacks one.
|
|
155
|
+
let cwd = summary.cwd;
|
|
156
|
+
if (!cwd) {
|
|
157
|
+
try {
|
|
158
|
+
cwd = decodeURIComponent(cwdEntry.name);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
cwd = undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let createdAt = summary.createdAt;
|
|
165
|
+
let updatedAt = summary.updatedAt;
|
|
166
|
+
if (createdAt == null || updatedAt == null) {
|
|
167
|
+
try {
|
|
168
|
+
const st = fs.statSync(dir);
|
|
169
|
+
createdAt = createdAt ?? (st.birthtimeMs || st.mtimeMs);
|
|
170
|
+
updatedAt = updatedAt ?? st.mtimeMs;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
out.push({
|
|
177
|
+
id: summary.id,
|
|
178
|
+
dir,
|
|
179
|
+
cwd,
|
|
180
|
+
createdAt,
|
|
181
|
+
updatedAt,
|
|
182
|
+
name: summary.name,
|
|
183
|
+
firstMessage: firstUserMessage(dir),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out.sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Load a Grok session's chat_history.jsonl as RuntimeMessage[] for history
|
|
191
|
+
* preload when a governed chat resumes that session. System scaffolding and
|
|
192
|
+
* tool_result rows are dropped — the chat surface only needs the user/assistant
|
|
193
|
+
* turns. Best-effort; a missing/malformed file returns [].
|
|
194
|
+
*/
|
|
195
|
+
export function loadGrokTranscript(sessionId) {
|
|
196
|
+
const match = listGrokSessions().find((s) => s.id === sessionId);
|
|
197
|
+
if (!match)
|
|
198
|
+
return [];
|
|
199
|
+
const file = path.join(match.dir, "chat_history.jsonl");
|
|
200
|
+
let raw;
|
|
201
|
+
try {
|
|
202
|
+
raw = fs.readFileSync(file, "utf8");
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const line of raw.split("\n")) {
|
|
209
|
+
if (!line.trim())
|
|
210
|
+
continue;
|
|
211
|
+
let rec;
|
|
212
|
+
try {
|
|
213
|
+
rec = JSON.parse(line);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const type = rec.type;
|
|
219
|
+
if (type !== "user" && type !== "assistant")
|
|
220
|
+
continue;
|
|
221
|
+
let text = contentText(rec.content).trim();
|
|
222
|
+
if (!text)
|
|
223
|
+
continue;
|
|
224
|
+
// Prefer the inner <user_query> when the CLI wrapped the prompt.
|
|
225
|
+
if (type === "user") {
|
|
226
|
+
const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim();
|
|
227
|
+
if (query)
|
|
228
|
+
text = query;
|
|
229
|
+
// Skip pure meta system blobs that aren't real user turns.
|
|
230
|
+
if (text.startsWith("<user_info>") ||
|
|
231
|
+
text.startsWith("<system-reminder>") ||
|
|
232
|
+
text.startsWith("<git_status>") ||
|
|
233
|
+
text.startsWith("<image_files>") ||
|
|
234
|
+
text.startsWith("<action_safety>")) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
out.push({
|
|
239
|
+
role: type === "user" ? "user" : "assistant",
|
|
240
|
+
content: text,
|
|
241
|
+
timestamp: typeof rec.timestamp === "string" || typeof rec.timestamp === "number" ? toEpoch(rec.timestamp) : undefined,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Locate the Grok session a run produced (by cwd + start time) when there is no
|
|
248
|
+
* launch-time pinned id. Mirrors discoverCodexSessionForCwd / discoverPiSessionForCwd.
|
|
249
|
+
*/
|
|
250
|
+
export function discoverGrokSessionForCwd(cwd, since = 0) {
|
|
251
|
+
const target = path.resolve(cwd);
|
|
252
|
+
const skewMs = 10_000;
|
|
253
|
+
const earliest = since - skewMs;
|
|
254
|
+
const wanted = process.platform === "win32" ? target.toLowerCase() : target;
|
|
255
|
+
return listGrokSessions()
|
|
256
|
+
.filter((s) => {
|
|
257
|
+
if (!s.cwd)
|
|
258
|
+
return false;
|
|
259
|
+
const resolved = process.platform === "win32" ? path.resolve(s.cwd).toLowerCase() : path.resolve(s.cwd);
|
|
260
|
+
return resolved === wanted;
|
|
261
|
+
})
|
|
262
|
+
.filter((s) => (s.createdAt ?? 0) >= earliest)
|
|
263
|
+
.sort((a, b) => {
|
|
264
|
+
const da = Math.abs((a.createdAt ?? 0) - since);
|
|
265
|
+
const db = Math.abs((b.createdAt ?? 0) - since);
|
|
266
|
+
if (da !== db)
|
|
267
|
+
return da - db;
|
|
268
|
+
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
|
|
269
|
+
})[0];
|
|
270
|
+
}
|
|
271
|
+
/** Issue #156 discovery surface for Grok's on-disk store. */
|
|
272
|
+
export function discoverNativeGrokSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, GROK_BIN_NAMES)) {
|
|
273
|
+
return listGrokSessions().map((s) => ({
|
|
274
|
+
runtimeId: "grok",
|
|
275
|
+
ref: s.id,
|
|
276
|
+
file: s.dir,
|
|
277
|
+
cwd: s.cwd,
|
|
278
|
+
updatedAt: s.updatedAt ?? s.createdAt,
|
|
279
|
+
title: s.name || s.firstMessage,
|
|
280
|
+
active: Boolean(s.cwd) && hasLiveProcess(s.cwd),
|
|
281
|
+
resumable: true,
|
|
282
|
+
}));
|
|
283
|
+
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "./claude-code.js";
|
|
10
10
|
import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout } from "./codex-sessions.js";
|
|
11
11
|
import { deleteOpenCodeSession, loadOpenCodeTranscript, writeOpenCodeHistory } from "./opencode-sessions.js";
|
|
12
|
+
import { discoverNativeGrokSessions, listGrokSessions, loadGrokTranscript } from "./grok-sessions.js";
|
|
12
13
|
import { createCredentialStore } from "./credentials.js";
|
|
13
14
|
// Args that continue an existing Codex session each prompt. Codex assigns its own
|
|
14
15
|
// session id (no launch-time pin), so a resumed run threads it via `exec resume`.
|
|
@@ -497,13 +498,25 @@ const CLI_AGENT_SPECS = {
|
|
|
497
498
|
// subscription → ~/.grok/auth.json (grok-auth.ts), API key → XAI_API_KEY /
|
|
498
499
|
// GROK_API_KEY. The older @vibe-kit/grok-cli only accepts API keys — install
|
|
499
500
|
// the official binary for OAuth to work.
|
|
501
|
+
//
|
|
502
|
+
// Resume: `grok --resume <id> -p "<prompt>"` continues a prior session by
|
|
503
|
+
// UUID (sessions live under ~/.grok/sessions/<cwd>/<id>/). The interactive
|
|
504
|
+
// TUI uses the same store via `grok --resume <id>`. Model ids match
|
|
505
|
+
// `grok models` for the official CLI (override with BIVY_GROK_MODELS).
|
|
500
506
|
args: ["-p"],
|
|
507
|
+
resume: {
|
|
508
|
+
template: ["--resume", "{id}", "-p"],
|
|
509
|
+
loadHistory: (sessionId) => loadGrokTranscript(sessionId),
|
|
510
|
+
},
|
|
501
511
|
model: {
|
|
502
512
|
flag: "-m",
|
|
503
513
|
models: [
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
514
|
+
// Official Grok CLI (1.x) currently advertises grok-4.5 as the default
|
|
515
|
+
// subscription model. Older curated ids (grok-4-latest, grok-code-fast-1,
|
|
516
|
+
// …) return "unknown model id" against current CLIs — keep the list
|
|
517
|
+
// honest; operators can override with BIVY_GROK_MODELS if their install
|
|
518
|
+
// exposes more.
|
|
519
|
+
{ id: "grok-4.5", name: "Grok 4.5", provider: "xai" },
|
|
507
520
|
],
|
|
508
521
|
},
|
|
509
522
|
promptMode: "argv",
|
|
@@ -1083,7 +1096,23 @@ function cliAgentInfo(id) {
|
|
|
1083
1096
|
// (BIVY_MCP_PROXY) — an honest, narrower capability than full toolInterception
|
|
1084
1097
|
// (it governs MCP tools, not the agent's built-in shell/edits). See
|
|
1085
1098
|
// src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
|
|
1086
|
-
capabilities: withExactCapabilitySurface({
|
|
1099
|
+
capabilities: withExactCapabilitySurface({
|
|
1100
|
+
toolInterception: acpActive,
|
|
1101
|
+
mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY),
|
|
1102
|
+
modelSelection,
|
|
1103
|
+
resume,
|
|
1104
|
+
packages: false,
|
|
1105
|
+
fork: false,
|
|
1106
|
+
usageReporting,
|
|
1107
|
+
// Codex (and Grok) can locate an on-disk session by cwd + start time so a
|
|
1108
|
+
// `bivy run` terminal without a launch-time pin is still takeable as chat.
|
|
1109
|
+
sessionDiscovery: id === "codex" || id === "grok",
|
|
1110
|
+
// Grok also exposes its interactive TUI (`grok --resume <id>`) for the
|
|
1111
|
+
// chat↔terminal hand-off; Codex gets this via the approvals runtime.
|
|
1112
|
+
interactiveTui: id === "grok" && commandAvailable(spec.command),
|
|
1113
|
+
nativeSessionDiscovery: id === "grok" && commandAvailable(spec.command),
|
|
1114
|
+
nativeSessionAdoption: id === "grok" && resume,
|
|
1115
|
+
}),
|
|
1087
1116
|
supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
|
|
1088
1117
|
testedVersion: spec.testedVersion,
|
|
1089
1118
|
authOwner: spec.authOwner ?? "agent",
|
|
@@ -1893,5 +1922,47 @@ function makeCliRuntime(id, options) {
|
|
|
1893
1922
|
: [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
|
|
1894
1923
|
}
|
|
1895
1924
|
: {};
|
|
1896
|
-
|
|
1925
|
+
// Grok-specific: on-disk session enumeration + interactive TUI hand-off so
|
|
1926
|
+
// `bivy run grok` sessions persist after the PTY exits and can be taken
|
|
1927
|
+
// over as chat or reopened in the native TUI.
|
|
1928
|
+
const grokOpts = id === "grok"
|
|
1929
|
+
? {
|
|
1930
|
+
sessionDiscovery: true,
|
|
1931
|
+
listDiskSessions: () => listGrokSessions().map((s) => ({
|
|
1932
|
+
id: s.id,
|
|
1933
|
+
path: s.dir,
|
|
1934
|
+
cwd: s.cwd,
|
|
1935
|
+
name: s.name || s.firstMessage,
|
|
1936
|
+
created: s.createdAt ? new Date(s.createdAt).toISOString() : undefined,
|
|
1937
|
+
modified: s.updatedAt ? new Date(s.updatedAt).toISOString() : undefined,
|
|
1938
|
+
firstMessage: s.firstMessage,
|
|
1939
|
+
})),
|
|
1940
|
+
interactiveTui: ({ sessionRef, env }) => sessionRef ? { command: "grok", args: ["--resume", sessionRef], env } : null,
|
|
1941
|
+
// discoverNativeSessions is on AgentRuntime; ProcessRuntime doesn't
|
|
1942
|
+
// expose it as an option — wire via a thin subclass below when needed.
|
|
1943
|
+
}
|
|
1944
|
+
: {};
|
|
1945
|
+
const runtime = new ProcessRuntime({
|
|
1946
|
+
id,
|
|
1947
|
+
displayName: spec.displayName,
|
|
1948
|
+
command: spec.command,
|
|
1949
|
+
args: runArgs,
|
|
1950
|
+
promptMode: spec.promptMode,
|
|
1951
|
+
credentials: createCredentialStore(options.credsDir),
|
|
1952
|
+
parserFactory: parserFactoryFor(parserId),
|
|
1953
|
+
preflight,
|
|
1954
|
+
prepare,
|
|
1955
|
+
model: cliModelConfig(id),
|
|
1956
|
+
thinking: cliThinkingConfig(id),
|
|
1957
|
+
usageReporting: cliUsageReporting(id),
|
|
1958
|
+
slashCommands: cliSlashCommands(id),
|
|
1959
|
+
...resumeOpts,
|
|
1960
|
+
...grokOpts,
|
|
1961
|
+
});
|
|
1962
|
+
if (id === "grok") {
|
|
1963
|
+
// Issue #156 discovery surface — ProcessRuntime has no options hook for
|
|
1964
|
+
// this; attach it so collectDiscoveredSessions picks Grok sessions up.
|
|
1965
|
+
runtime.discoverNativeSessions = () => discoverNativeGrokSessions();
|
|
1966
|
+
}
|
|
1967
|
+
return runtime;
|
|
1897
1968
|
}
|
package/dist/runtime/process.js
CHANGED
|
@@ -194,6 +194,33 @@ class ProcessSession {
|
|
|
194
194
|
setName(name) {
|
|
195
195
|
this.name = name;
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* "Continue in terminal": hand this session to the agent's interactive TUI
|
|
199
|
+
* (see ProcessRuntimeOptions.interactiveTui). Runs prepare() first so auth
|
|
200
|
+
* env (e.g. GROK_HOME) matches the chat path.
|
|
201
|
+
*/
|
|
202
|
+
async interactiveTuiCommand() {
|
|
203
|
+
const hook = this.runtimeOptions.interactiveTui;
|
|
204
|
+
if (!hook)
|
|
205
|
+
return null;
|
|
206
|
+
const sessionRef = this.resumeId ?? this.id;
|
|
207
|
+
let env = {};
|
|
208
|
+
if (this.runtimeOptions.prepare) {
|
|
209
|
+
try {
|
|
210
|
+
const patch = await this.runtimeOptions.prepare({});
|
|
211
|
+
if (patch && typeof patch === "object") {
|
|
212
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
213
|
+
if (typeof v === "string")
|
|
214
|
+
env[k] = v;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
env = {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return hook({ sessionRef, cwd: this.cwd, env });
|
|
223
|
+
}
|
|
197
224
|
/** The agent's on-disk slash commands for this workspace (Codex prompts,
|
|
198
225
|
* opencode commands). Best-effort and display-only: any read failure yields an
|
|
199
226
|
* empty menu, never a throw. */
|
|
@@ -460,6 +487,10 @@ export class ProcessRuntime {
|
|
|
460
487
|
fork: false,
|
|
461
488
|
// Usage reporting when a usage-emitting parser is wired (see getUsage).
|
|
462
489
|
usageReporting: Boolean(options.usageReporting),
|
|
490
|
+
interactiveTui: Boolean(options.interactiveTui),
|
|
491
|
+
sessionDiscovery: Boolean(options.sessionDiscovery),
|
|
492
|
+
nativeSessionDiscovery: Boolean(options.listDiskSessions),
|
|
493
|
+
nativeSessionAdoption: Boolean(options.resumable && options.listDiskSessions),
|
|
463
494
|
});
|
|
464
495
|
}
|
|
465
496
|
/** Session-less catalog contribution: this CLI agent's configured models, grouped by provider. */
|
|
@@ -494,6 +525,17 @@ export class ProcessRuntime {
|
|
|
494
525
|
return this.createSession(options);
|
|
495
526
|
}
|
|
496
527
|
async listSessions() {
|
|
528
|
+
// Prefer the agent's own on-disk store when wired (Grok, …) so closed
|
|
529
|
+
// `bivy run` / native TUI sessions still appear in the durable list after
|
|
530
|
+
// the PTY exits — the in-memory set alone evaporates with the process.
|
|
531
|
+
if (this.options.listDiskSessions) {
|
|
532
|
+
try {
|
|
533
|
+
return await this.options.listDiskSessions();
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// fall through to the live handles
|
|
537
|
+
}
|
|
538
|
+
}
|
|
497
539
|
return this.sessions.map((session) => ({
|
|
498
540
|
id: session.id,
|
|
499
541
|
cwd: session.cwd,
|
package/dist/server.js
CHANGED
|
@@ -31,6 +31,7 @@ import { suggestNameFromSelectedModel } from "./runtime/model-namer.js";
|
|
|
31
31
|
import { isNativeOAuthProvider, loginModelOAuth } from "./runtime/oauth/model-oauth.js";
|
|
32
32
|
import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
|
|
33
33
|
import { listCodexSessions, loadCodexTranscript, discoverCodexSessionForCwd } from "./runtime/codex-sessions.js";
|
|
34
|
+
import { discoverGrokSessionForCwd } from "./runtime/grok-sessions.js";
|
|
34
35
|
import { dedupeSessionSummaries } from "./session-identity.js";
|
|
35
36
|
import { discoverPiSessionForCwd } from "./runtime/pi-session-discovery.js";
|
|
36
37
|
import { exportProviderAuth, exportProviderAuthTombstones, importProviderAuth, listProviders, removeProvider, setProviderApiKey, setProviderCredential } from "./runtime/pi-auth.js";
|
|
@@ -1783,6 +1784,9 @@ async function openRunTerminal(spec, emit) {
|
|
|
1783
1784
|
// Only genuine `bivy run` agents get quiet-agent push hints; an adopted
|
|
1784
1785
|
// tmux/zellij/screen attach isn't "your agent waiting".
|
|
1785
1786
|
const notifiable = !spec.mux;
|
|
1787
|
+
// Captured before open so the onExit discovery path (agents that assign their
|
|
1788
|
+
// session id lazily) can correlate by start time.
|
|
1789
|
+
const createdAt = Date.now();
|
|
1786
1790
|
try {
|
|
1787
1791
|
const id = terminals.open({
|
|
1788
1792
|
workspace,
|
|
@@ -1820,6 +1824,40 @@ async function openRunTerminal(spec, emit) {
|
|
|
1820
1824
|
clearRunIdleNotify(id);
|
|
1821
1825
|
broadcast({ type: "terminal.exit", termId: id, code });
|
|
1822
1826
|
broadcast({ type: "terminal.closed", termId: id });
|
|
1827
|
+
// Mark the durable row idle and, for agents that assign their id lazily
|
|
1828
|
+
// (no launch-time pin), discover the on-disk session now so it still
|
|
1829
|
+
// appears in the sidebar after the PTY is gone.
|
|
1830
|
+
if (!spec.mux && spec.agent) {
|
|
1831
|
+
const agentId = spec.agent;
|
|
1832
|
+
void (async () => {
|
|
1833
|
+
let sessionRef = spec.sessionId;
|
|
1834
|
+
if (!sessionRef) {
|
|
1835
|
+
try {
|
|
1836
|
+
sessionRef = await SESSION_DISCOVERY_BY_AGENT[agentId]?.(workspace, createdAt);
|
|
1837
|
+
}
|
|
1838
|
+
catch {
|
|
1839
|
+
/* agent may never have written a session */
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (sessionRef) {
|
|
1843
|
+
const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[agentId] ?? agentId;
|
|
1844
|
+
try {
|
|
1845
|
+
metadata.upsertSession({
|
|
1846
|
+
id: sessionRef,
|
|
1847
|
+
runtimeId,
|
|
1848
|
+
agentName: agentId,
|
|
1849
|
+
workspace,
|
|
1850
|
+
name: name || undefined,
|
|
1851
|
+
source: "cli",
|
|
1852
|
+
status: "saved",
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
catch {
|
|
1856
|
+
/* best-effort */
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
})();
|
|
1860
|
+
}
|
|
1823
1861
|
// Unify keys, reverse direction: a login done inside the agent's own CLI
|
|
1824
1862
|
// (e.g. `codex login`) is folded back into Bivy's vault, then synced.
|
|
1825
1863
|
if (!spec.mux) {
|
|
@@ -1835,9 +1873,37 @@ async function openRunTerminal(spec, emit) {
|
|
|
1835
1873
|
},
|
|
1836
1874
|
});
|
|
1837
1875
|
runTerminals.add(id);
|
|
1838
|
-
|
|
1876
|
+
// Persist a durable metadata row when the run has a known session id (pinned
|
|
1877
|
+
// at launch, e.g. `claude --session-id` / `grok --session-id`). Without this,
|
|
1878
|
+
// a `bivy run` session vanishes from the sidebar the moment the PTY exits —
|
|
1879
|
+
// only live run-terminals were listed. The metadata backfill in
|
|
1880
|
+
// listAllSessions then keeps it resumable after close.
|
|
1881
|
+
if (spec.sessionId && spec.agent && !spec.mux) {
|
|
1882
|
+
const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[spec.agent] ?? spec.agent;
|
|
1883
|
+
try {
|
|
1884
|
+
metadata.upsertSession({
|
|
1885
|
+
id: spec.sessionId,
|
|
1886
|
+
runtimeId,
|
|
1887
|
+
agentName: spec.agent,
|
|
1888
|
+
workspace,
|
|
1889
|
+
name: name || undefined,
|
|
1890
|
+
source: "cli",
|
|
1891
|
+
status: "working",
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
catch {
|
|
1895
|
+
/* best-effort: a metadata write must never block the run launch */
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1839
1898
|
emit({ type: "terminal.opened", termId: id, workspace, mode: "run", agent: spec.agent, label, name });
|
|
1840
1899
|
broadcast({ type: "terminal.created", terminal: { termId: id, workspace, createdAt, lastActivityAt: createdAt, kind: "run", agent: spec.agent, model: spec.model, label, name, command: commandLine, mux: spec.mux, sessionId: spec.sessionId, pid: terminals.pid(id) } });
|
|
1900
|
+
// Surface the durable row so the sidebar keeps the session after the PTY
|
|
1901
|
+
// exits (listAllSessions backfills from metadata). Best-effort broadcast.
|
|
1902
|
+
if (spec.sessionId && !spec.mux) {
|
|
1903
|
+
void listAllSessions()
|
|
1904
|
+
.then((sessions) => broadcast({ type: "sessions.list", sessions: sessions.map((s) => ({ ...s, sessionId: s.id })) }))
|
|
1905
|
+
.catch(() => { });
|
|
1906
|
+
}
|
|
1841
1907
|
return id;
|
|
1842
1908
|
}
|
|
1843
1909
|
catch (error) {
|
|
@@ -1856,11 +1922,15 @@ const TAKEOVER_RUNTIME_BY_AGENT = {
|
|
|
1856
1922
|
// exec-created session resumes through the app-server with full context), so
|
|
1857
1923
|
// the adopted chat gets per-tool approvals, not just the exec jail.
|
|
1858
1924
|
codex: "codex-approvals",
|
|
1925
|
+
// Official Grok CLI: resume by UUID via `grok --resume <id> -p …` (process
|
|
1926
|
+
// runtime) after the interactive TUI is stopped.
|
|
1927
|
+
grok: "grok",
|
|
1859
1928
|
};
|
|
1860
1929
|
// The CLI command to continue an adopted session back in a terminal.
|
|
1861
1930
|
const RESUME_CLI_BY_AGENT = {
|
|
1862
1931
|
claude: (id) => `claude --resume ${id}`,
|
|
1863
1932
|
codex: (id) => `codex resume ${id}`,
|
|
1933
|
+
grok: (id) => `grok --resume ${id}`,
|
|
1864
1934
|
};
|
|
1865
1935
|
// Same commands, keyed by runtime id instead of the short takeover agent alias
|
|
1866
1936
|
// above — for native session discovery (issue #156), where a session with a
|
|
@@ -1871,6 +1941,7 @@ const RESUME_CLI_BY_AGENT = {
|
|
|
1871
1941
|
const NATIVE_RESUME_CLI_BY_RUNTIME = {
|
|
1872
1942
|
"claude-code-sdk": (id) => `claude --resume ${id}`,
|
|
1873
1943
|
"codex-approvals": (id) => `codex resume ${id}`,
|
|
1944
|
+
grok: (id) => `grok --resume ${id}`,
|
|
1874
1945
|
};
|
|
1875
1946
|
// When no resumable session id can be found, why — and what to do about it.
|
|
1876
1947
|
// These agents assign their id lazily (Codex/Pi write a session only once a turn
|
|
@@ -1878,6 +1949,7 @@ const NATIVE_RESUME_CLI_BY_RUNTIME = {
|
|
|
1878
1949
|
const TAKEOVER_EMPTY_HINT_BY_AGENT = {
|
|
1879
1950
|
codex: "Codex writes its session only after the first message. Send one message in the terminal, then continue as chat.",
|
|
1880
1951
|
pi: "Pi assigns its session once the conversation starts. Send one message in the terminal, then continue as chat.",
|
|
1952
|
+
grok: "Grok writes its session once the conversation starts. Send one message in the terminal, then continue as chat.",
|
|
1881
1953
|
};
|
|
1882
1954
|
// For agents with no launch-time session-id pin, locate the on-disk session a run
|
|
1883
1955
|
// produced (by cwd + start time) so a takeover can still resume the right one.
|
|
@@ -1890,6 +1962,10 @@ const SESSION_DISCOVERY_BY_AGENT = {
|
|
|
1890
1962
|
const match = discoverPiSessionForCwd(await runtimeHost.listSessions(getRuntime("pi")), cwd, since);
|
|
1891
1963
|
return match?.path || match?.id;
|
|
1892
1964
|
},
|
|
1965
|
+
// Grok can be pinned at launch (`--session-id`) but also assigns ids lazily
|
|
1966
|
+
// when the pin flag is absent; correlate by cwd + start time against
|
|
1967
|
+
// ~/.grok/sessions.
|
|
1968
|
+
grok: (cwd, since) => discoverGrokSessionForCwd(cwd, since)?.id,
|
|
1893
1969
|
};
|
|
1894
1970
|
// "Continue as chat": stop the native TUI running in a pinned run-terminal and
|
|
1895
1971
|
// reopen its session as a governed, structured chat on the owning runtime. The
|
package/dist/terminal.js
CHANGED
|
@@ -16,7 +16,11 @@ import { depCacheEnv } from "./harness/dep-cache.js";
|
|
|
16
16
|
* that by an order of magnitude while adding latency well below the threshold of
|
|
17
17
|
* perception for interactive echo.
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
// 16ms keeps a single chatty TUI under the relay's default node message budget
|
|
20
|
+
// (6000/min ≈ 10ms/frame). At 8ms a fullscreen redraw stream could exceed that
|
|
21
|
+
// and the relay closed the socket with "Rate limit exceeded" the moment a
|
|
22
|
+
// phone/app attached to a live `bivy run` terminal.
|
|
23
|
+
const OUTPUT_FLUSH_MS = 16;
|
|
20
24
|
/**
|
|
21
25
|
* How much recent output to retain per terminal for scrollback replay on
|
|
22
26
|
* reconnect. A phone that backgrounds the PWA (or drops its network) reattaches
|
package/package.json
CHANGED