@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.
@@ -191,7 +191,8 @@
191
191
  "supportTier": "beta",
192
192
  "certification": "adapter-tested",
193
193
  "headlessFlags": [
194
- "-p"
194
+ "-p",
195
+ "--resume"
195
196
  ],
196
197
  "install": {
197
198
  "kind": "curl",
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
+ }
@@ -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
- { id: "grok-code-fast-1", name: "Grok Code Fast 1", provider: "xai" },
505
- { id: "grok-4-latest", name: "Grok 4", provider: "xai" },
506
- { id: "grok-3-fast", name: "Grok 3 Fast", provider: "xai" },
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({ toolInterception: acpActive, mcpToolApprovals: acpActive || Boolean(process.env.BIVY_MCP_PROXY), modelSelection, resume, packages: false, fork: false, usageReporting, sessionDiscovery: id === "codex" }),
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
- return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), slashCommands: cliSlashCommands(id), ...resumeOpts });
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
  }
@@ -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
- const createdAt = Date.now();
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
- const OUTPUT_FLUSH_MS = 8;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.151",
3
+ "version": "0.10.1-staging.152",
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.",