@bivy/bivy 0.10.1-staging.150 → 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 +5 -3
- package/bin/bivy.mjs +4 -0
- package/dist/runtime/credential-ingest.js +49 -0
- package/dist/runtime/credential-provisioning.js +9 -0
- package/dist/runtime/credentials.js +8 -2
- package/dist/runtime/grok-auth.js +103 -0
- package/dist/runtime/grok-preflight.js +61 -0
- package/dist/runtime/grok-sessions.js +283 -0
- package/dist/runtime/index.js +102 -17
- 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
|
@@ -191,11 +191,13 @@
|
|
|
191
191
|
"supportTier": "beta",
|
|
192
192
|
"certification": "adapter-tested",
|
|
193
193
|
"headlessFlags": [
|
|
194
|
-
"-p"
|
|
194
|
+
"-p",
|
|
195
|
+
"--resume"
|
|
195
196
|
],
|
|
196
197
|
"install": {
|
|
197
|
-
"kind": "
|
|
198
|
-
"
|
|
198
|
+
"kind": "curl",
|
|
199
|
+
"display": "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
200
|
+
"shell": "curl -fsSL https://x.ai/cli/install.sh | bash"
|
|
199
201
|
}
|
|
200
202
|
},
|
|
201
203
|
{
|
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()];
|
|
@@ -14,6 +14,7 @@ import fs from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { createCredentialVault } from "./credential-store.js";
|
|
16
16
|
import { resolveCodexHome } from "./codex-auth.js";
|
|
17
|
+
import { grokAuthEntryKey, resolveGrokHome, GROK_OIDC_ISSUER } from "./grok-auth.js";
|
|
17
18
|
import { claudeCredentialFiles } from "./anthropic-preflight.js";
|
|
18
19
|
/** Read the `exp` (seconds) claim from a JWT access token → absolute epoch ms. */
|
|
19
20
|
function jwtExpiryMs(token) {
|
|
@@ -94,6 +95,52 @@ export async function ingestClaudeCredentials(credsDir) {
|
|
|
94
95
|
}
|
|
95
96
|
return 0;
|
|
96
97
|
}
|
|
98
|
+
/** Map the official Grok CLI's `~/.grok/auth.json` OIDC entry into a Bivy credential. */
|
|
99
|
+
export function grokAuthToCredential(raw) {
|
|
100
|
+
if (!raw || typeof raw !== "object")
|
|
101
|
+
return undefined;
|
|
102
|
+
const record = raw;
|
|
103
|
+
// Prefer the known xAI OIDC entry key; fall back to the first oidc entry whose
|
|
104
|
+
// issuer is auth.x.ai (covers a client-id change without breaking ingest).
|
|
105
|
+
const preferredKey = grokAuthEntryKey();
|
|
106
|
+
let entry = record[preferredKey];
|
|
107
|
+
if (!entry || typeof entry !== "object") {
|
|
108
|
+
for (const value of Object.values(record)) {
|
|
109
|
+
if (!value || typeof value !== "object")
|
|
110
|
+
continue;
|
|
111
|
+
const candidate = value;
|
|
112
|
+
if (candidate.oidc_issuer === GROK_OIDC_ISSUER || candidate.auth_mode === "oidc") {
|
|
113
|
+
entry = candidate;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!entry)
|
|
119
|
+
return undefined;
|
|
120
|
+
const access = typeof entry.key === "string" ? entry.key : "";
|
|
121
|
+
const refresh = typeof entry.refresh_token === "string" ? entry.refresh_token : "";
|
|
122
|
+
if (!access || !refresh)
|
|
123
|
+
return undefined;
|
|
124
|
+
const expiresFromField = typeof entry.expires_at === "string" ? Date.parse(entry.expires_at) : NaN;
|
|
125
|
+
const expires = Number.isFinite(expiresFromField) && expiresFromField > 0
|
|
126
|
+
? expiresFromField
|
|
127
|
+
: (jwtExpiryMs(access) ?? Date.now() + 60 * 60 * 1000);
|
|
128
|
+
return { providerId: "xai", credential: { type: "oauth", access, refresh, expires } };
|
|
129
|
+
}
|
|
130
|
+
/** Fold a login/refresh done in the Grok CLI back into Bivy's vault. */
|
|
131
|
+
export async function ingestGrokCredentials(credsDir) {
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = JSON.parse(fs.readFileSync(path.join(resolveGrokHome(), "auth.json"), "utf8"));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return 0; // no Grok login on disk
|
|
138
|
+
}
|
|
139
|
+
const mapped = grokAuthToCredential(raw);
|
|
140
|
+
if (!mapped)
|
|
141
|
+
return 0;
|
|
142
|
+
return createCredentialVault(credsDir).importAll({ [mapped.providerId]: mapped.credential });
|
|
143
|
+
}
|
|
97
144
|
/**
|
|
98
145
|
* Ingest an agent's native credential store into Bivy's vault. Called when a
|
|
99
146
|
* `bivy run <agent>` terminal exits, so a login done inside that agent's CLI is
|
|
@@ -112,6 +159,8 @@ export async function ingestAgentCredentials(agentId, credsDir, piDir) {
|
|
|
112
159
|
case "claude-code":
|
|
113
160
|
case "claude-code-sdk":
|
|
114
161
|
return ingestClaudeCredentials(credsDir);
|
|
162
|
+
case "grok":
|
|
163
|
+
return ingestGrokCredentials(credsDir);
|
|
115
164
|
case "pi":
|
|
116
165
|
// Pi's TUI writes a plaintext auth.json in its own dir; fold it back into
|
|
117
166
|
// the shared vault.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { createCredentialVault } from "./credential-store.js";
|
|
20
20
|
import { buildAgentCredentialEnv, createCredentialStore } from "./credentials.js";
|
|
21
21
|
import { ensureCodexAuth, ensureCodexTrusted } from "./codex-auth.js";
|
|
22
|
+
import { ensureGrokAuth } from "./grok-auth.js";
|
|
22
23
|
import { refreshModelOAuth } from "./oauth/model-oauth.js";
|
|
23
24
|
import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
|
|
24
25
|
/**
|
|
@@ -75,5 +76,13 @@ export async function provisionAgentRun(credsDir, piDir, agentId, workspace) {
|
|
|
75
76
|
if (workspace)
|
|
76
77
|
ensureCodexTrusted(workspace);
|
|
77
78
|
}
|
|
79
|
+
else if (agentId === "grok") {
|
|
80
|
+
// Official Grok CLI reads XAI_API_KEY/GROK_API_KEY (already in env for api
|
|
81
|
+
// keys) or its own auth.json; mint the latter from a connected SuperGrok /
|
|
82
|
+
// X subscription when present.
|
|
83
|
+
const home = await ensureGrokAuth(credsDir).catch(() => undefined);
|
|
84
|
+
if (home)
|
|
85
|
+
env.GROK_HOME = home;
|
|
86
|
+
}
|
|
78
87
|
return env;
|
|
79
88
|
}
|
|
@@ -128,8 +128,9 @@ export async function buildAgentCredentialEnv(store, providers, activeProvider)
|
|
|
128
128
|
if (cred.kind === "oauth") {
|
|
129
129
|
// OAuth *subscription* tokens are provider-specific and are not accepted
|
|
130
130
|
// as a plain API key. Only Anthropic has a documented env var an external
|
|
131
|
-
// agent reads (Claude Code's OAuth token)
|
|
132
|
-
//
|
|
131
|
+
// agent reads (Claude Code's OAuth token). Codex and Grok get their
|
|
132
|
+
// subscription via native auth.json materialization (codex-auth.ts /
|
|
133
|
+
// grok-auth.ts), not as env keys — so don't emit a misleading key here.
|
|
133
134
|
if (cred.provider !== "anthropic")
|
|
134
135
|
continue;
|
|
135
136
|
Object.assign(env, cred.env ?? {});
|
|
@@ -137,6 +138,11 @@ export async function buildAgentCredentialEnv(store, providers, activeProvider)
|
|
|
137
138
|
}
|
|
138
139
|
else {
|
|
139
140
|
env[apiKeyEnvVar(cred.provider)] = cred.token;
|
|
141
|
+
// vibe-kit/grok-cli (and some forks) read GROK_API_KEY instead of the
|
|
142
|
+
// official XAI_API_KEY. Project both so either Grok binary works off one
|
|
143
|
+
// Bivy API-key login.
|
|
144
|
+
if (cred.provider === "xai")
|
|
145
|
+
env.GROK_API_KEY = cred.token;
|
|
140
146
|
// A custom endpoint's base URL (and standard-key alias) is injected only
|
|
141
147
|
// when it's the active provider, so it can't hijack the real provider.
|
|
142
148
|
if (isActive && cred.env) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Grok (xAI CLI) credential materialization.
|
|
4
|
+
//
|
|
5
|
+
// An xAI SuperGrok / X subscription a user connects *inside Bivy* lands in the
|
|
6
|
+
// shared node vault as an `xai` OAuth record. The official Grok CLI
|
|
7
|
+
// (`curl -fsSL https://x.ai/cli/install.sh | bash`) cannot read that vault — it
|
|
8
|
+
// authenticates from `XAI_API_KEY` / `GROK_API_KEY` or its own
|
|
9
|
+
// `$GROK_HOME/auth.json` (the file `grok login` writes). This module bridges the
|
|
10
|
+
// gap: it mints the file Grok expects from the vault record, so a subscription
|
|
11
|
+
// connected in Bivy "just works" for the Grok agent with no separate
|
|
12
|
+
// `grok login`.
|
|
13
|
+
//
|
|
14
|
+
// Why it's sound: Bivy's `xai` OAuth app IS the Grok CLI's own OAuth app
|
|
15
|
+
// (identical client_id `b1a00492-…`, token endpoint, and scopes including
|
|
16
|
+
// `grok-cli:access` — verified against @earendil-works/pi-ai and the shipped
|
|
17
|
+
// grok binary), so tokens minted through Bivy are accepted by Grok's backend.
|
|
18
|
+
// The vault already stores {access, refresh, expires}; we write them in the
|
|
19
|
+
// shape Grok's auth.json uses for OIDC sessions.
|
|
20
|
+
//
|
|
21
|
+
// Rotation note: xAI does *not* rotate the refresh token on every grant
|
|
22
|
+
// (`refreshRotates: false` in model-oauth-providers), so minting is safe even
|
|
23
|
+
// if Grok later self-refreshes — the vault's refresh token stays valid. We mint
|
|
24
|
+
// only when no auth.json exists yet (Grok then owns and self-refreshes it),
|
|
25
|
+
// matching the Codex materialization policy.
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { createCredentialVault } from "./credential-store.js";
|
|
30
|
+
import { getModelOAuthProvider } from "./oauth/model-oauth-providers.js";
|
|
31
|
+
import { refreshModelOAuth } from "./oauth/model-oauth.js";
|
|
32
|
+
/** xAI OIDC issuer the official Grok CLI keys its auth.json entry on. */
|
|
33
|
+
export const GROK_OIDC_ISSUER = "https://auth.x.ai";
|
|
34
|
+
/** Resolve Grok's home dir exactly as the CLI does (`GROK_HOME` or `~/.grok`). */
|
|
35
|
+
export function resolveGrokHome() {
|
|
36
|
+
return process.env.GROK_HOME?.trim() || path.join(os.homedir(), ".grok");
|
|
37
|
+
}
|
|
38
|
+
/** Map key Grok uses for the xAI OIDC session inside auth.json. */
|
|
39
|
+
export function grokAuthEntryKey(clientId) {
|
|
40
|
+
const id = clientId?.trim() || getModelOAuthProvider("xai")?.clientId || "";
|
|
41
|
+
return `${GROK_OIDC_ISSUER}::${id}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Ensure the Grok CLI has a usable credential file, minting one from Bivy's
|
|
45
|
+
* vault when needed. Returns the resolved `GROK_HOME` (so the caller can pin it
|
|
46
|
+
* on the subprocess env) or `undefined` when there's nothing to do / no vault
|
|
47
|
+
* credential — in which case the caller's preflight surfaces the actionable
|
|
48
|
+
* "no credential" error unchanged.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent and low-churn: if an `auth.json` already exists (a native
|
|
51
|
+
* `grok login` or a prior materialization) it is left untouched — Grok owns and
|
|
52
|
+
* self-refreshes it — so we mint at most once. We write to the *default* Grok
|
|
53
|
+
* home (never a throwaway dir) so sessions stay where the CLI already looks.
|
|
54
|
+
*/
|
|
55
|
+
export async function ensureGrokAuth(credsDir) {
|
|
56
|
+
const grokHome = resolveGrokHome();
|
|
57
|
+
const authFile = path.join(grokHome, "auth.json");
|
|
58
|
+
// Never clobber an existing login (native or previously materialized); Grok
|
|
59
|
+
// owns and refreshes it. An API key, if present, is handled by preflight /
|
|
60
|
+
// env projection — no auth.json needed.
|
|
61
|
+
if (fs.existsSync(authFile))
|
|
62
|
+
return grokHome;
|
|
63
|
+
if (process.env.XAI_API_KEY?.trim() || process.env.GROK_API_KEY?.trim())
|
|
64
|
+
return undefined;
|
|
65
|
+
const provider = getModelOAuthProvider("xai");
|
|
66
|
+
if (!provider)
|
|
67
|
+
return undefined;
|
|
68
|
+
// Ensure the vault access token is still live before we project it. No-op when
|
|
69
|
+
// fresh; refreshes under the store lock when expired. Failure leaves the vault
|
|
70
|
+
// alone and we fall through to the (likely still-stale) read below.
|
|
71
|
+
await refreshModelOAuth(credsDir, "xai").catch(() => undefined);
|
|
72
|
+
const store = createCredentialVault(credsDir);
|
|
73
|
+
const cred = await store.read("xai").catch(() => undefined);
|
|
74
|
+
if (!cred || cred.type !== "oauth")
|
|
75
|
+
return undefined;
|
|
76
|
+
const access = typeof cred.access === "string" ? cred.access : "";
|
|
77
|
+
const refresh = typeof cred.refresh === "string" ? cred.refresh : "";
|
|
78
|
+
if (!access || !refresh)
|
|
79
|
+
return undefined;
|
|
80
|
+
const expiresMs = Number(cred.expires) || 0;
|
|
81
|
+
const entryKey = grokAuthEntryKey(provider.clientId);
|
|
82
|
+
const entry = {
|
|
83
|
+
key: access,
|
|
84
|
+
auth_mode: "oidc",
|
|
85
|
+
create_time: new Date().toISOString(),
|
|
86
|
+
refresh_token: refresh,
|
|
87
|
+
oidc_issuer: GROK_OIDC_ISSUER,
|
|
88
|
+
oidc_client_id: provider.clientId,
|
|
89
|
+
};
|
|
90
|
+
if (expiresMs > 0)
|
|
91
|
+
entry.expires_at = new Date(expiresMs).toISOString();
|
|
92
|
+
const authJson = { [entryKey]: entry };
|
|
93
|
+
try {
|
|
94
|
+
fs.mkdirSync(grokHome, { recursive: true, mode: 0o700 });
|
|
95
|
+
const tmp = `${authFile}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, `${JSON.stringify(authJson, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
fs.renameSync(tmp, authFile);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return grokHome;
|
|
103
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Grok (xAI CLI) credential preflight.
|
|
4
|
+
//
|
|
5
|
+
// The official Grok CLI authenticates one of two ways: an xAI API key read from
|
|
6
|
+
// `XAI_API_KEY` / `GROK_API_KEY`, or OIDC tokens in `$GROK_HOME/auth.json`
|
|
7
|
+
// (default `~/.grok/auth.json`). Bivy's shared credential vault forwards *API
|
|
8
|
+
// keys* to every agent (see credentials.ts), and `grok-auth.ts` mints the
|
|
9
|
+
// auth.json from a connected xAI *subscription* — so both sign-in kinds now
|
|
10
|
+
// reach Grok.
|
|
11
|
+
//
|
|
12
|
+
// This preflight is the backstop: if neither an API key nor an auth.json is
|
|
13
|
+
// present after that handoff, the CLI fails with an opaque "API key required"
|
|
14
|
+
// (vibe-kit) or "Not signed in" (official) message. We detect that up front and
|
|
15
|
+
// return a clear, actionable message instead.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/** Path to the Grok CLI's own auth file (`GROK_HOME/auth.json`, default `~/.grok`). */
|
|
20
|
+
export function grokAuthFile(deps = {}) {
|
|
21
|
+
const grokHome = (deps.grokHome ?? process.env.GROK_HOME)?.trim();
|
|
22
|
+
const base = grokHome ? grokHome : path.join(deps.home ?? os.homedir(), ".grok");
|
|
23
|
+
return path.join(base, "auth.json");
|
|
24
|
+
}
|
|
25
|
+
/** The user-facing message shown when Grok has no xAI credential. */
|
|
26
|
+
export const GROK_NO_CREDENTIAL_MESSAGE = [
|
|
27
|
+
"Grok has no xAI credential on this node, so it can't start a turn.",
|
|
28
|
+
"",
|
|
29
|
+
"Fix it any of these ways:",
|
|
30
|
+
"• Connect SuperGrok / X Premium under Keys & OAuth — Bivy mints Grok's auth file from it automatically, or",
|
|
31
|
+
"• Add an xAI API key under Keys & OAuth — Bivy passes it as XAI_API_KEY (and GROK_API_KEY), or",
|
|
32
|
+
"• Run `grok login` on this node to sign in directly (official Grok CLI).",
|
|
33
|
+
"",
|
|
34
|
+
"Install the official Grok CLI if you haven't: `curl -fsSL https://x.ai/cli/install.sh | bash`",
|
|
35
|
+
"(The older @vibe-kit/grok-cli package only accepts API keys and cannot use a subscription login.)",
|
|
36
|
+
"",
|
|
37
|
+
"If you already connected the xAI subscription in Bivy and still see this, the saved login could not be projected — reconnect it under Keys & OAuth, or delete a stale ~/.grok/auth.json and try again.",
|
|
38
|
+
].join("\n");
|
|
39
|
+
/**
|
|
40
|
+
* Returns a human-readable error when the Grok CLI has no usable xAI
|
|
41
|
+
* credential, or undefined when a credential is present so the turn should
|
|
42
|
+
* proceed. `env` is the environment the Grok subprocess will run with (ambient
|
|
43
|
+
* process env + Bivy's vault handoff).
|
|
44
|
+
*/
|
|
45
|
+
export function grokCredentialPreflight(env, deps = {}) {
|
|
46
|
+
if (env.XAI_API_KEY?.trim() || env.GROK_API_KEY?.trim())
|
|
47
|
+
return undefined;
|
|
48
|
+
const exists = deps.fileExists ?? ((p) => {
|
|
49
|
+
try {
|
|
50
|
+
return fs.existsSync(p);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Prefer the GROK_HOME the prepare step may have just pinned on env.
|
|
57
|
+
const homeFromEnv = env.GROK_HOME?.trim();
|
|
58
|
+
if (exists(grokAuthFile({ ...deps, grokHome: homeFromEnv || deps.grokHome })))
|
|
59
|
+
return undefined;
|
|
60
|
+
return GROK_NO_CREDENTIAL_MESSAGE;
|
|
61
|
+
}
|
|
@@ -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`.
|
|
@@ -37,7 +38,9 @@ import { PiRuntime } from "./pi.js";
|
|
|
37
38
|
import { ProcessRuntime, processRuntimeFromEnv } from "./process.js";
|
|
38
39
|
import { codexCredentialPreflight } from "./codex-preflight.js";
|
|
39
40
|
import { opencodeCredentialPreflight } from "./opencode-preflight.js";
|
|
41
|
+
import { grokCredentialPreflight } from "./grok-preflight.js";
|
|
40
42
|
import { ensureCodexAuth } from "./codex-auth.js";
|
|
43
|
+
import { ensureGrokAuth } from "./grok-auth.js";
|
|
41
44
|
import { parserFactoryFor } from "./cli-parsers.js";
|
|
42
45
|
import { sandboxTier, sandboxArgsFor, codexSandboxPolicy } from "../harness/sandbox.js";
|
|
43
46
|
import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from "./protocol.js";
|
|
@@ -487,24 +490,41 @@ const CLI_AGENT_SPECS = {
|
|
|
487
490
|
grok: {
|
|
488
491
|
displayName: "Grok",
|
|
489
492
|
command: "grok",
|
|
490
|
-
packageName: "
|
|
493
|
+
packageName: "grok (curl -fsSL https://x.ai/cli/install.sh | bash)",
|
|
491
494
|
supportTier: "beta",
|
|
492
|
-
blurb: "
|
|
493
|
-
// `grok -p "<prompt>"` runs one
|
|
494
|
-
// the model.
|
|
495
|
-
//
|
|
496
|
-
//
|
|
495
|
+
blurb: "xAI's official Grok coding agent (Grok CLI) — SuperGrok/X subscription or API key.",
|
|
496
|
+
// Official CLI: `grok -p "<prompt>"` (alias `--single`) runs one headless
|
|
497
|
+
// turn; `-m <id>` picks the model. Auth is projected from Bivy's vault:
|
|
498
|
+
// subscription → ~/.grok/auth.json (grok-auth.ts), API key → XAI_API_KEY /
|
|
499
|
+
// GROK_API_KEY. The older @vibe-kit/grok-cli only accepts API keys — install
|
|
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).
|
|
497
506
|
args: ["-p"],
|
|
507
|
+
resume: {
|
|
508
|
+
template: ["--resume", "{id}", "-p"],
|
|
509
|
+
loadHistory: (sessionId) => loadGrokTranscript(sessionId),
|
|
510
|
+
},
|
|
498
511
|
model: {
|
|
499
512
|
flag: "-m",
|
|
500
513
|
models: [
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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" },
|
|
504
520
|
],
|
|
505
521
|
},
|
|
506
522
|
promptMode: "argv",
|
|
507
|
-
install: {
|
|
523
|
+
install: {
|
|
524
|
+
kind: "curl",
|
|
525
|
+
display: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
526
|
+
shell: "curl -fsSL https://x.ai/cli/install.sh | bash",
|
|
527
|
+
},
|
|
508
528
|
},
|
|
509
529
|
amp: {
|
|
510
530
|
displayName: "Amp",
|
|
@@ -1076,7 +1096,23 @@ function cliAgentInfo(id) {
|
|
|
1076
1096
|
// (BIVY_MCP_PROXY) — an honest, narrower capability than full toolInterception
|
|
1077
1097
|
// (it governs MCP tools, not the agent's built-in shell/edits). See
|
|
1078
1098
|
// src/harness/mcp-inject.ts + governMcpCall in src/server.ts.
|
|
1079
|
-
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
|
+
}),
|
|
1080
1116
|
supportTier: spec.supportTier ?? (id === "codex" ? "supported" : "experimental"),
|
|
1081
1117
|
testedVersion: spec.testedVersion,
|
|
1082
1118
|
authOwner: spec.authOwner ?? "agent",
|
|
@@ -1835,22 +1871,29 @@ function makeCliRuntime(id, options) {
|
|
|
1835
1871
|
: structured && spec.jsonArgs
|
|
1836
1872
|
? spec.jsonArgs
|
|
1837
1873
|
: spec.args);
|
|
1838
|
-
// Codex
|
|
1839
|
-
// user connected a
|
|
1840
|
-
// `
|
|
1874
|
+
// Codex / Grok read an API key *or* their own `$…_HOME/auth.json`. When the
|
|
1875
|
+
// user connected a subscription in Bivy (but hasn't run `codex login` /
|
|
1876
|
+
// `grok login`), `prepare` mints that auth file from the shared vault so
|
|
1841
1877
|
// the run just works; the preflight still catches the genuinely
|
|
1842
1878
|
// uncredentialed case with an actionable message instead of an opaque 401.
|
|
1843
1879
|
const preflight = id === "codex"
|
|
1844
1880
|
? (env) => codexCredentialPreflight(env)
|
|
1845
1881
|
: id === "opencode"
|
|
1846
1882
|
? (env, ctx) => opencodeCredentialPreflight(env, ctx)
|
|
1847
|
-
:
|
|
1883
|
+
: id === "grok"
|
|
1884
|
+
? (env) => grokCredentialPreflight(env)
|
|
1885
|
+
: undefined;
|
|
1848
1886
|
const prepare = id === "codex"
|
|
1849
1887
|
? async () => {
|
|
1850
1888
|
const home = await ensureCodexAuth(options.credsDir);
|
|
1851
1889
|
return home ? { CODEX_HOME: home } : {};
|
|
1852
1890
|
}
|
|
1853
|
-
:
|
|
1891
|
+
: id === "grok"
|
|
1892
|
+
? async () => {
|
|
1893
|
+
const home = await ensureGrokAuth(options.credsDir);
|
|
1894
|
+
return home ? { GROK_HOME: home } : {};
|
|
1895
|
+
}
|
|
1896
|
+
: undefined;
|
|
1854
1897
|
// Resume, the generic way. Codex keeps its verified path (rollout history +
|
|
1855
1898
|
// tier-aware `codex exec resume <id> --json`). Every other CLI agent becomes
|
|
1856
1899
|
// resumable purely as data: a spec.resume template (or a BIVY_<ID>_RESUME_
|
|
@@ -1879,5 +1922,47 @@ function makeCliRuntime(id, options) {
|
|
|
1879
1922
|
: [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
|
|
1880
1923
|
}
|
|
1881
1924
|
: {};
|
|
1882
|
-
|
|
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;
|
|
1883
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