pi-grok-agent 0.1.0

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/src/client.ts ADDED
@@ -0,0 +1,75 @@
1
+ // WebSocket transport to the Grok gateway: one JSON-RPC message per frame, bearer secret in the header.
2
+ import type { AnyMessage, Stream } from '@agentclientprotocol/sdk';
3
+ import WebSocket from 'ws';
4
+
5
+ export type ConnectionOptions = { url: string; secret: string };
6
+
7
+ export function validateEndpoint(value: string): string {
8
+ const url = new URL(value);
9
+ if (!['ws:', 'wss:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
10
+ throw new Error('Use a ws:// or wss:// endpoint without credentials, query, or fragment.');
11
+ }
12
+ if (url.protocol === 'ws:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
13
+ throw new Error('Use wss:// for a non-loopback server.');
14
+ }
15
+ return url.toString();
16
+ }
17
+
18
+ /** One JSON-RPC message per WebSocket frame; no Pi filesystem/terminal bridge. */
19
+ export async function openSocket(options: ConnectionOptions, signal?: AbortSignal): Promise<{ stream: Stream; close(): void; closed: Promise<void> }> {
20
+ signal?.throwIfAborted();
21
+ const url = validateEndpoint(options.url);
22
+ if (!options.secret.trim()) throw new Error('Grok WebSocket secret is missing.');
23
+ const socket = new WebSocket(url, {
24
+ headers: { Authorization: `Bearer ${options.secret}` },
25
+ handshakeTimeout: 10_000,
26
+ maxPayload: 16 * 1024 * 1024,
27
+ followRedirects: false,
28
+ });
29
+ let input: ReadableStreamDefaultController<AnyMessage>;
30
+ let ended = false;
31
+ const end = (error?: Error) => {
32
+ if (ended) return;
33
+ ended = true;
34
+ if (error) input.error(error); else input.close();
35
+ };
36
+ const readable = new ReadableStream<AnyMessage>({
37
+ start(controller) { input = controller; },
38
+ cancel() { ended = true; socket.terminate(); }
39
+ });
40
+ socket.on('message', (data) => {
41
+ if (ended) return;
42
+ try { input.enqueue(JSON.parse(data.toString())); }
43
+ catch { end(new Error('Invalid JSON from Grok WebSocket.')); socket.terminate(); }
44
+ });
45
+ let resolveClosed!: () => void;
46
+ const closed = new Promise<void>((resolve) => { resolveClosed = resolve; });
47
+ socket.on('close', () => { end(); resolveClosed(); });
48
+ socket.on('error', () => end(new Error('Grok WebSocket connection failed. Check the endpoint, server, and secret.')));
49
+ await new Promise<void>((resolve, reject) => {
50
+ const cleanup = () => { signal?.removeEventListener('abort', abort); socket.off('open', opened); socket.off('error', failed); socket.off('close', closed); };
51
+ const opened = () => { cleanup(); resolve(); };
52
+ const failed = () => { cleanup(); socket.terminate(); reject(new Error('Grok WebSocket handshake failed. Check the endpoint, server, and secret.')); };
53
+ const closed = () => { cleanup(); reject(new Error('Grok WebSocket closed during connection.')); };
54
+ const abort = () => { cleanup(); socket.terminate(); reject(new Error('Grok connection cancelled.')); };
55
+ socket.once('open', opened); socket.once('error', failed); socket.once('close', closed);
56
+ signal?.addEventListener('abort', abort, { once: true });
57
+ if (signal?.aborted) abort();
58
+ });
59
+ return {
60
+ stream: {
61
+ readable,
62
+ writable: new WritableStream<AnyMessage>({
63
+ write(message) {
64
+ return new Promise<void>((resolve, reject) => {
65
+ socket.send(JSON.stringify(message), (error) => error ? reject(new Error('Grok WebSocket send failed.')) : resolve());
66
+ });
67
+ },
68
+ close() { socket.close(); },
69
+ abort() { socket.terminate(); },
70
+ }),
71
+ },
72
+ close() { socket.terminate(); },
73
+ closed,
74
+ };
75
+ }
package/src/config.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { validateEndpoint } from './client.ts';
5
+
6
+ export const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
7
+ export const configPath = join(agentDir, 'grok-ws.json');
8
+ export const defaultSecretFile = join(agentDir, 'grok-ws.secret');
9
+
10
+ /** Which Pi tools the `grok` model provider offers to Grok in addition to Grok's own harness tools. */
11
+ export type PiToolPolicy = 'none' | 'extensions' | 'all' | string[];
12
+ export const PI_CORE_TOOLS = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
13
+
14
+ /**
15
+ * How the `grok` model provider answers Grok's native permission prompts when Pi has no UI (`-p`, Fabric workers).
16
+ * `dialog` (default): ask in Pi's UI; without a UI, deny. `deny`: always reject. `allow`: always allow once.
17
+ * `reads`: allow read-class prompts, deny the rest. Interactive Pi always shows the dialog.
18
+ */
19
+ export type HeadlessPermissionPolicy = 'dialog' | 'deny' | 'reads' | 'allow';
20
+ /**
21
+ * Grok-side permission mode for sessions the provider creates, independent of Pi's `/grok perms` gate.
22
+ * `default`: Grok's normal rules. `auto`: Grok's auto permission mode (`_meta.autoMode`). A third value is accepted but not documented.
23
+ */
24
+ export type GrokMode = 'default' | 'auto' | 'yolo';
25
+
26
+ /**
27
+ * Gateway guard tiers, in milliseconds. Grok fails OPEN when a client hook times out and waits forever on a
28
+ * permission prompt, so the gateway answers on Pi's behalf when Pi cannot. Each value is a deadline after which
29
+ * the gateway answers fail-closed (deny / continue / reject) unless Pi has answered.
30
+ */
31
+ export type GuardSettings = {
32
+ /** No `pi/gate-ack` from Pi within this window: Pi is hung or gone. Default 5000. */
33
+ ackMs?: number;
34
+ /** Acked without dialog or check: a policy answer is expected promptly. Default 15000. */
35
+ policyMs?: number;
36
+ /** Acked with `check: true` (post_tool_use / stop running a command). Default 590000. */
37
+ checkBudgetMs?: number;
38
+ /** Acked with `dialog: true` (a human is deciding). Default 600000. */
39
+ dialogMs?: number;
40
+ };
41
+ /** Grok's own client-hook deadline cap (`MAX_HOOK_TIMEOUT_SECS`); every guard tier for hooks must stay below it. */
42
+ export const GROK_HOOK_CAP_MS = 600_000;
43
+ /** Grok's deadline for pre_tool_use as registered by this package (`CLIENT_HOOKS`); ackMs and policyMs must stay below it. */
44
+ export const GATE_REGISTRATION_MS = 30_000;
45
+ const GUARD_DEFAULTS: Required<GuardSettings> = { ackMs: 5_000, policyMs: 15_000, checkBudgetMs: 590_000, dialogMs: 600_000 };
46
+
47
+ export function resolveGuard(settings: GuardSettings | undefined, env: NodeJS.ProcessEnv = process.env): Required<GuardSettings> {
48
+ const pick = (key: keyof GuardSettings, envName: string): number => {
49
+ const raw = env[envName] ?? settings?.[key];
50
+ if (raw === undefined || raw === '') return GUARD_DEFAULTS[key];
51
+ const n = Number(raw);
52
+ if (!Number.isFinite(n) || n <= 0) throw new Error(`guard.${key} must be a positive number of milliseconds (got ${String(raw)}).`);
53
+ return Math.floor(n);
54
+ };
55
+ const guard = { ackMs: pick('ackMs', 'PI_GROK_ACK_MS'), policyMs: pick('policyMs', 'PI_GROK_POLICY_MS'), checkBudgetMs: pick('checkBudgetMs', 'PI_GROK_CHECK_BUDGET_MS'), dialogMs: pick('dialogMs', 'PI_GROK_DIALOG_MS') };
56
+ // A tier at or past Grok's own deadline would let Grok fail open first, which defeats the guard.
57
+ if (guard.ackMs >= GATE_REGISTRATION_MS || guard.policyMs >= GATE_REGISTRATION_MS) throw new Error('guard.ackMs and guard.policyMs must be below the pre_tool_use registration deadline (' + GATE_REGISTRATION_MS + ' ms).');
58
+ if (guard.checkBudgetMs >= GROK_HOOK_CAP_MS) throw new Error('guard.checkBudgetMs must be below Grok\'s hook cap (' + GROK_HOOK_CAP_MS + ' ms).');
59
+ if (guard.ackMs > guard.policyMs) throw new Error('guard.ackMs must not exceed guard.policyMs.');
60
+ return guard;
61
+ }
62
+
63
+ /** Optional overrides for the Grok-side hook layers. Regexes match Grok tool names. */
64
+ export type HookSettings = {
65
+ /** Extra Grok tools to deny at pre_tool_use, beyond the capability mirror. */
66
+ denyGrokTools?: string[];
67
+ /** Grok tools to allow even when the capability mirror would deny them. */
68
+ allowGrokTools?: string[];
69
+ /**
70
+ * MCP servers (plugin or configured) whose tools count as read-only in a read-only Pi session.
71
+ * Grok 1.0.41 drops MCP `annotations` (readOnlyHint), so per-tool hints are only honored from `_meta`.
72
+ */
73
+ mcpReadOnlyServers?: string[];
74
+ /** Command run after a Grok edit; `{file}` is replaced. Non-zero exit output becomes additionalContext. Default: built-in syntax checks. */
75
+ postEditCheck?: string;
76
+ /** Command run when Grok wants to end its turn; non-zero exit blocks the stop with the output as reason. */
77
+ stopCheck?: string;
78
+ };
79
+
80
+ /** The gateway's shared secret, or undefined when the file does not exist yet. Other read errors propagate. */
81
+ export async function readSecretFile(secretFile: string): Promise<string | undefined> {
82
+ try { return (await readFile(secretFile, 'utf8')).trim(); }
83
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; }
84
+ }
85
+
86
+ export async function readConfig() {
87
+ let settings: { url?: string; secretFile?: string; piTools?: PiToolPolicy; hooks?: HookSettings; headlessPermissions?: HeadlessPermissionPolicy; guard?: GuardSettings; mediaDir?: string; grokMode?: GrokMode; autoStartGateway?: boolean } = {};
88
+ try { settings = JSON.parse(await readFile(configPath, 'utf8')); }
89
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
90
+ const url = validateEndpoint(process.env.GROK_ACP_URL || settings.url || 'ws://127.0.0.1:2419/ws');
91
+ const configuredFile = settings.secretFile || defaultSecretFile;
92
+ const secretFile = configuredFile.startsWith('~/') ? join(homedir(), configuredFile.slice(2)) : configuredFile;
93
+ if (!isAbsolute(secretFile)) throw new Error('grok-ws secretFile must be absolute or start with ~/.');
94
+ // A missing secret file is not a load error: Pi exits on any extension load failure, and the gateway that creates
95
+ // the file may not have run yet. The connection reads the file again when it opens (`readSecretFile`).
96
+ const secret = process.env.GROK_AGENT_SECRET || (await readSecretFile(secretFile)) || '';
97
+ const piTools: PiToolPolicy = process.env.PI_GROK_PI_TOOLS ? parsePolicy(process.env.PI_GROK_PI_TOOLS) : settings.piTools ?? 'extensions';
98
+ const hooks: HookSettings = { ...settings.hooks };
99
+ if (process.env.PI_GROK_STOP_CHECK) hooks.stopCheck = process.env.PI_GROK_STOP_CHECK;
100
+ if (process.env.PI_GROK_POST_EDIT_CHECK) hooks.postEditCheck = process.env.PI_GROK_POST_EDIT_CHECK;
101
+ if (process.env.PI_GROK_DENY_TOOLS) hooks.denyGrokTools = process.env.PI_GROK_DENY_TOOLS.split(',').map((s) => s.trim()).filter(Boolean);
102
+ const headlessPermissions = (process.env.PI_GROK_HEADLESS_PERMISSIONS as HeadlessPermissionPolicy | undefined) ?? settings.headlessPermissions ?? 'dialog';
103
+ if (!['dialog', 'deny', 'reads', 'allow'].includes(headlessPermissions)) throw new Error(`headlessPermissions must be dialog, deny, reads, or allow (got ${headlessPermissions}).`);
104
+ const guard = resolveGuard(settings.guard);
105
+ // Where Pi copies Grok's generated media. Relative paths resolve against the Pi session cwd. Empty string disables the copy.
106
+ const mediaDir = process.env.PI_GROK_MEDIA_DIR ?? settings.mediaDir ?? '.pi/grok-images';
107
+ const grokMode = (process.env.PI_GROK_GROK_MODE as GrokMode | undefined) ?? settings.grokMode ?? 'default';
108
+ if (!['default', 'auto', 'yolo'].includes(grokMode)) throw new Error(`grokMode must be default, auto, or yolo (got ${grokMode}).`);
109
+ // Start the bundled gateway when nothing listens on the endpoint. PI_GROK_AUTOSTART=0 or autoStartGateway: false turns it off.
110
+ const autoStartGateway = process.env.PI_GROK_AUTOSTART ? !['0', 'false', 'no', 'off'].includes(process.env.PI_GROK_AUTOSTART.toLowerCase()) : settings.autoStartGateway ?? true;
111
+ return { url, secret, secretFile, piTools, hooks, headlessPermissions, guard, mediaDir, grokMode, autoStartGateway };
112
+ }
113
+
114
+ function parsePolicy(value: string): PiToolPolicy {
115
+ if (value === 'none' || value === 'extensions' || value === 'all') return value;
116
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
117
+ }
118
+
119
+ export function selectPiTools<T extends { name: string }>(tools: T[], policy: PiToolPolicy): T[] {
120
+ if (policy === 'none') return [];
121
+ if (policy === 'all') return tools;
122
+ if (policy === 'extensions') return tools.filter((t) => !PI_CORE_TOOLS.has(t.name));
123
+ const allowed = new Set(policy);
124
+ return tools.filter((t) => allowed.has(t.name));
125
+ }
package/src/launch.ts ADDED
@@ -0,0 +1,55 @@
1
+ // Start the gateway that ships with this package when nothing listens on its loopback endpoint, so a user needs only
2
+ // `pi install npm:pi-grok-agent`. The gateway runs detached and outlives Pi: every Pi process on the machine shares it.
3
+ // Two Pi processes that start one at the same time are safe: the gateway binds its port before it touches a leader,
4
+ // so the loser exits with EADDRINUSE and both connect to the winner.
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync, mkdirSync, openSync } from 'node:fs';
7
+ import { createConnection } from 'node:net';
8
+ import { homedir } from 'node:os';
9
+ import { basename, dirname, join, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
13
+
14
+ /** The gateway entry point for this install. Node refuses to strip TypeScript under node_modules, so an npm install runs the compiled copy. */
15
+ export function gatewayEntry(root = packageRoot): string {
16
+ const source = join(root, 'scripts', 'server.ts');
17
+ const compiled = join(root, 'dist', 'scripts', 'server.js');
18
+ if (root.includes(`${sep}node_modules${sep}`) || !existsSync(source)) return compiled;
19
+ return source;
20
+ }
21
+
22
+ /** True when something accepts TCP connections on the endpoint's host and port. */
23
+ export function endpointListening(endpoint: string, timeoutMs = 1000): Promise<boolean> {
24
+ const url = new URL(endpoint);
25
+ const host = url.hostname === '[::1]' ? '::1' : url.hostname;
26
+ return new Promise((resolve) => {
27
+ const socket = createConnection({ host, port: Number(url.port || 80) });
28
+ const done = (value: boolean) => { socket.destroy(); resolve(value); };
29
+ socket.setTimeout(timeoutMs, () => done(false));
30
+ socket.once('connect', () => done(true));
31
+ socket.once('error', () => done(false));
32
+ });
33
+ }
34
+
35
+ /** Start the gateway detached, logging to `<agentDir>/grok-ws.log`, and wait until its port accepts connections. Returns its pid. */
36
+ export async function launchGateway(endpoint: string, logDir: string, timeoutMs = 30_000): Promise<number | undefined> {
37
+ const entry = gatewayEntry();
38
+ if (!existsSync(entry)) throw new Error(`Grok gateway not found at ${entry}. Run npm run build in the package, or start the gateway yourself.`);
39
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
40
+ const log = openSync(join(logDir, 'grok-ws.log'), 'a', 0o600);
41
+ // Inside Pi, execPath is the Node binary that runs Pi. A single-file Pi build would be Pi itself; use `node` then.
42
+ const nodeBinary = /^node(\.exe)?$/.test(basename(process.execPath)) ? process.execPath : 'node';
43
+ const child = spawn(nodeBinary, [entry], { detached: true, stdio: ['ignore', log, log], cwd: homedir(), env: process.env });
44
+ child.unref();
45
+ // Exited without a port: usually another Pi's launch won the port a moment earlier, so keep waiting a few seconds for it.
46
+ let exitedAt: number | undefined;
47
+ child.once('exit', () => { exitedAt = Date.now(); });
48
+ child.once('error', () => { exitedAt = Date.now(); });
49
+ const deadline = Date.now() + timeoutMs;
50
+ while (Date.now() < deadline && (exitedAt === undefined || Date.now() - exitedAt < 5000)) {
51
+ if (await endpointListening(endpoint, 500)) return exitedAt === undefined ? child.pid : undefined;
52
+ await new Promise((r) => setTimeout(r, 200));
53
+ }
54
+ throw new Error(`Grok gateway did not start within ${Math.round(timeoutMs / 1000)}s. See ${join(logDir, 'grok-ws.log')}.`);
55
+ }
package/src/login.ts ADDED
@@ -0,0 +1,44 @@
1
+ // `/grok login`: run Grok Build's own device-code sign-in and report the URL and code to Pi. Grok stores the
2
+ // credential in ~/.grok/auth.json, as a `grok login` in a terminal would; Pi stores nothing.
3
+ import { spawn } from 'node:child_process';
4
+
5
+ export type DeviceCode = { url: string; code: string };
6
+
7
+ /** Pull the verification URL and user code from `grok login --device-auth` output. */
8
+ export function parseDeviceCode(output: string): DeviceCode | undefined {
9
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
10
+ const url = plain.match(/https:\/\/\S+user_code=([A-Z0-9-]+)/);
11
+ if (!url) return undefined;
12
+ return { url: url[0], code: url[1] };
13
+ }
14
+
15
+ /**
16
+ * Start the sign-in. `onCode` fires once with the URL and code; the promise settles when Grok's login exits.
17
+ * Grok may also open the URL in a browser itself. The abort signal stops the login.
18
+ */
19
+ export function grokLogin(onCode: (device: DeviceCode) => void, options: { binary?: string; signal?: AbortSignal; timeoutMs?: number } = {}): Promise<void> {
20
+ const binary = options.binary ?? process.env.PI_GROK_BINARY ?? 'grok';
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(binary, ['login', '--device-auth'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
23
+ let output = ''; let reported = false;
24
+ const read = (chunk: Buffer) => {
25
+ output += chunk.toString();
26
+ if (reported) return;
27
+ const device = parseDeviceCode(output);
28
+ if (device) { reported = true; onCode(device); }
29
+ };
30
+ child.stdout.on('data', read);
31
+ child.stderr.on('data', read);
32
+ const stop = () => child.kill('SIGTERM');
33
+ const timer = setTimeout(stop, options.timeoutMs ?? 15 * 60_000);
34
+ options.signal?.addEventListener('abort', stop, { once: true });
35
+ child.once('error', (error) => { clearTimeout(timer); reject(new Error(`Could not run ${binary} login: ${error.message}`)); });
36
+ child.once('exit', (code, signal) => {
37
+ clearTimeout(timer);
38
+ options.signal?.removeEventListener('abort', stop);
39
+ if (code === 0) { resolve(); return; }
40
+ const tail = output.replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n').slice(-2).join(' ').trim();
41
+ reject(new Error(`Grok login ${signal ? 'stopped' : `failed (exit ${code})`}${tail ? `: ${tail}` : ''}`));
42
+ });
43
+ });
44
+ }
@@ -0,0 +1,239 @@
1
+ // One WebSocket to Grok, many sessions. Grok keeps its full native harness (its own tools,
2
+ // permissions, subagents). Pi tools are offered additively as an HTTP MCP server that the gateway
3
+ // fronts at /mcp/<serverId>; the gateway relays each MCP message back over this socket as an
4
+ // _x.ai/mcp/sdk_call request, routed here by serverId. The stock leader never sees that traffic.
5
+ import { client, type ClientConnection, type InitializeResponse, type NewSessionResponse, type LoadSessionResponse, type SessionNotification, type RequestPermissionRequest, type RequestPermissionResponse } from '@agentclientprotocol/sdk';
6
+ import { openSocket, type ConnectionOptions } from '../client.ts';
7
+ import { readSecretFile } from '../config.ts';
8
+ import { endpointListening, launchGateway } from '../launch.ts';
9
+ import { GATE_REGISTRATION_MS } from '../config.ts';
10
+
11
+ export type McpToolDefinition = { name: string; description: string; inputSchema: Record<string, unknown> };
12
+ export type McpToolResult = { content: { type: 'text'; text: string }[] | { type: 'image'; data: string; mimeType: string }[] | ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[]; isError?: boolean };
13
+ export type SdkCall = { method: string; id: unknown; params?: any };
14
+
15
+ export interface SessionHandlers {
16
+ onUpdate(notification: SessionNotification): void;
17
+ /** Answer one MCP JSON-RPC message from Grok. Return the JSON-RPC `result` or throw for an error. */
18
+ onMcp(message: SdkCall): Promise<unknown>;
19
+ onPermission?(request: RequestPermissionRequest): Promise<RequestPermissionResponse>;
20
+ /**
21
+ * Blocking client hook (`_x.ai/hooks/run`): pre_tool_use, post_tool_use, stop. `gate.dialog()` tells the gateway a
22
+ * human is deciding, so it waits the dialog window instead of the short policy window before answering for Pi.
23
+ */
24
+ onHookRun?(payload: any, gate?: { dialog(): void }): Promise<Record<string, unknown>>;
25
+ /** Passive client hook notification (`_x.ai/hooks/event`). */
26
+ onHookEvent?(payload: any): void;
27
+ /** Grok's ask_user_question (`_x.ai/ask_user_question`). Default: cancelled. */
28
+ onQuestion?(request: any): Promise<Record<string, unknown>>;
29
+ /** Grok's extension session notifications (`_x.ai/session_notification`): turn_completed carries full token usage. */
30
+ onSessionExt?(update: any): void;
31
+ }
32
+
33
+ /**
34
+ * Client hook registration sent in session/new. Grok caps timeouts at 600 s and fails OPEN on expiry.
35
+ * The gate gets a short deadline; the gateway denies on Pi's behalf well before it (PI_GROK_GATE_DENY_MS).
36
+ */
37
+ export const CLIENT_HOOKS = {
38
+ PreToolUse: [{ hookCallbackIds: ['pi-pre'], timeout: GATE_REGISTRATION_MS / 1000 }],
39
+ PostToolUse: [{ hookCallbackIds: ['pi-post'], timeout: 600 }],
40
+ PostToolUseFailure: [{ hookCallbackIds: ['pi-post-failure'], timeout: 60 }],
41
+ Stop: [{ hookCallbackIds: ['pi-stop'], timeout: 600 }],
42
+ };
43
+
44
+ type SdkCallParams = { serverId: string; sessionId?: string; message: SdkCall };
45
+
46
+ export class GrokModelConnection {
47
+ private socket?: Awaited<ReturnType<typeof openSocket>>;
48
+ private connection?: ClientConnection;
49
+ private initialized?: InitializeResponse;
50
+ private readonly sessions = new Map<string, SessionHandlers>();
51
+ private readonly servers = new Map<string, string>(); // serverId -> sessionId
52
+ private opening?: Promise<void>;
53
+ private closed = false;
54
+ private readonly options: ConnectionOptions & { secretFile?: string; autoStart?: { logDir: string } };
55
+
56
+ /**
57
+ * `secret` may be empty when the gateway has not created its file yet; `secretFile` is read again on each open.
58
+ * With `autoStart`, an open that finds nothing listening on a loopback `ws://` endpoint starts the bundled gateway.
59
+ */
60
+ constructor(options: ConnectionOptions & { secretFile?: string; autoStart?: { logDir: string } }) { this.options = { ...options }; }
61
+
62
+ /** Pid of the gateway this connection started, if any. */
63
+ launchedGateway?: number;
64
+
65
+ private async ensureGateway() {
66
+ if (!this.options.autoStart || !this.options.url.startsWith('ws://')) return;
67
+ if (await endpointListening(this.options.url)) return;
68
+ this.launchedGateway = await launchGateway(this.options.url, this.options.autoStart.logDir);
69
+ }
70
+
71
+ /** Fill in the secret from its file on first open, so Pi loads and the gateway may start after it. */
72
+ private async resolveSecret() {
73
+ if (this.options.secret.trim() || !this.options.secretFile) return;
74
+ const secret = await readSecretFile(this.options.secretFile);
75
+ if (!secret) throw new Error(`Grok gateway secret not found at ${this.options.secretFile}. Start the gateway once (pi-grok-gateway, or npm run server in the clone); it creates the file. Then send the message again.`);
76
+ this.options.secret = secret;
77
+ }
78
+
79
+ /** Reasons the last connection ended, for status and error text. */
80
+ lastDrop?: string;
81
+
82
+ get isOpen() { return !!this.connection && !this.closed; }
83
+
84
+ /** The socket went away underneath us (gateway restart, network). Sessions stay in the map so attach() can session/load them. */
85
+ private markDropped(reason: string) {
86
+ if (!this.connection && !this.socket) return;
87
+ this.lastDrop = reason;
88
+ this.connection = undefined;
89
+ this.socket = undefined;
90
+ this.initialized = undefined;
91
+ this.generation++;
92
+ }
93
+
94
+ /** Increments on every drop; a session attached under an older generation must session/load again. */
95
+ generation = 0;
96
+
97
+ async open(signal?: AbortSignal) {
98
+ if (this.connection) return;
99
+ if (this.opening) return this.opening;
100
+ this.closed = false;
101
+ this.opening = (async () => {
102
+ await this.ensureGateway();
103
+ await this.resolveSecret();
104
+ const socket = await openSocket(this.options, signal);
105
+ this.socket = socket;
106
+ void socket.closed.then(() => { if (this.socket === socket) this.markDropped('Grok WebSocket closed'); });
107
+ this.connection = client({ name: 'pi-grok-model' })
108
+ .onNotification('session/update', ({ params }) => {
109
+ this.sessions.get(params.sessionId)?.onUpdate(params);
110
+ })
111
+ .onRequest('session/request_permission', async ({ params }) => {
112
+ const handler = this.sessions.get(params.sessionId)?.onPermission;
113
+ this.ack(`perm:${params.toolCall?.toolCallId ?? ''}`, { dialog: this.hasUI });
114
+ return handler ? handler(params) : { outcome: { outcome: 'cancelled' } };
115
+ })
116
+ .onRequest('_x.ai/ask_user_question', (raw) => raw as any, async ({ params }) => {
117
+ const handler = this.sessions.get(params.sessionId ?? params.session_id)?.onQuestion;
118
+ this.ack(`ask:${params.toolCallId ?? params.tool_call_id ?? ''}`, { dialog: this.hasUI });
119
+ return handler ? handler(params) : { outcome: 'cancelled' };
120
+ })
121
+ .onRequest('_x.ai/hooks/run', (raw) => raw as any, async ({ params }) => {
122
+ const handler = this.sessions.get(params.sessionId ?? params.session_id)?.onHookRun;
123
+ const event = String(params.hookEventName ?? '');
124
+ const key = event === 'stop' ? `stop:${params.sessionId ?? params.session_id ?? ''}` : `${event}:${params.toolUseId ?? ''}`;
125
+ this.ack(key, { check: event === 'post_tool_use' || event === 'stop' });
126
+ if (handler) return handler(params, { dialog: () => this.ack(key, { dialog: true }) });
127
+ // No Pi session owns this Grok session (detached by /new or shutdown while a turn was still running).
128
+ // Pi's capability gate is gone, so tool use is denied rather than left to Grok's own permission mode.
129
+ if (event === 'pre_tool_use') return { decision: 'deny', reason: 'Pi detached from this Grok session; tool use is denied until a Pi session owns it again.' };
130
+ return { decision: 'continue' };
131
+ })
132
+ .onNotification('_x.ai/session_notification', (raw) => raw as any, ({ params }) => {
133
+ this.sessions.get(params.sessionId ?? params.session_id)?.onSessionExt?.(params.update);
134
+ })
135
+ .onNotification('_x.ai/hooks/event', (raw) => raw as any, ({ params }) => {
136
+ this.sessions.get(params.sessionId ?? params.session_id)?.onHookEvent?.(params);
137
+ })
138
+ .onRequest('_x.ai/mcp/sdk_call', (raw) => raw as SdkCallParams, async ({ params }) => {
139
+ const sessionId = params.sessionId ?? this.servers.get(params.serverId);
140
+ const handlers = sessionId ? this.sessions.get(sessionId) : undefined;
141
+ const { message } = params;
142
+ if (!handlers) return { jsonrpc: '2.0', id: message.id, error: { code: -32001, message: `no session for server ${params.serverId}` } };
143
+ try {
144
+ const result = await handlers.onMcp(message);
145
+ return { jsonrpc: '2.0', id: message.id, result };
146
+ } catch (error) {
147
+ return { jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error instanceof Error ? error.message : String(error) } };
148
+ }
149
+ })
150
+ .connect(socket.stream);
151
+ const agent = this.connection.agent;
152
+ this.initialized = await agent.request('initialize', {
153
+ protocolVersion: 1,
154
+ clientInfo: { name: 'pi-grok-model', version: '0.1.0' },
155
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
156
+ _meta: { 'x.ai/mcp/sdk': true },
157
+ });
158
+ if ((this.initialized.authMethods ?? []).some((m) => m.id === 'cached_token')) {
159
+ await agent.request('authenticate', { methodId: 'cached_token' });
160
+ } else {
161
+ // Grok offers `cached_token` only with a stored login. Drop this connection so the turn after a login
162
+ // initializes again and sees the new credential.
163
+ this.socket?.close();
164
+ this.markDropped('Grok Build is not signed in');
165
+ throw new Error('Grok Build is not signed in. Run /grok login, approve the code in your browser, then send the message again.');
166
+ }
167
+ })().finally(() => { this.opening = undefined; });
168
+ return this.opening;
169
+ }
170
+
171
+ get agent() {
172
+ if (!this.connection) throw new Error('Grok model connection is not open.');
173
+ return this.connection.agent;
174
+ }
175
+
176
+ /** Whether a human can answer dialogs; the gateway extends permission deadlines when true. */
177
+ hasUI = false;
178
+
179
+ /**
180
+ * Tell the gateway Pi is alive and what it is doing with a reverse request (`pi/gate-ack`).
181
+ * The gateway consumes this; it never reaches Grok. Missing acks make the gateway fail closed. A later ack for
182
+ * the same key moves the request to that tier's deadline (for example a hook that turns into a dialog).
183
+ */
184
+ private ack(key: string, state: { dialog?: boolean; check?: boolean }) {
185
+ void this.connection?.agent.notify('pi/gate-ack', { key, ...state }).catch(() => {});
186
+ }
187
+
188
+ /** HTTP origin of the gateway that fronts this WebSocket (ws://host:port -> http://host:port). */
189
+ get mcpBaseUrl() { return new URL(this.options.url).origin.replace(/^ws/, 'http'); }
190
+
191
+ /** Create or load a Grok session with Grok's native harness intact. `offerPiTools` adds the Pi-hosted MCP server. */
192
+ async attachSession(input: { sessionId?: string; cwd: string; serverId: string; serverName: string; rules?: string; handlers: SessionHandlers; toolTimeoutMs?: number; offerPiTools: boolean; hooks?: boolean; grokMode?: 'default' | 'auto' | 'yolo' }) {
193
+ const _meta: Record<string, unknown> = { yoloMode: input.grokMode === 'yolo', ...(input.grokMode === 'auto' ? { autoMode: true } : {}) };
194
+ if (input.hooks !== false) _meta['x.ai/hooks'] = CLIENT_HOOKS;
195
+ const mcpServers: unknown[] = [];
196
+ if (input.offerPiTools) {
197
+ mcpServers.push({ type: 'http', name: input.serverName, url: `${this.mcpBaseUrl}/mcp/${input.serverId}`, headers: [] });
198
+ _meta.mcpConfig = { [input.serverName]: { toolTimeoutMs: input.toolTimeoutMs ?? 6 * 60 * 60 * 1000 } };
199
+ }
200
+ if (input.rules) _meta.rules = input.rules;
201
+ const params = { cwd: input.cwd, mcpServers, _meta };
202
+ this.servers.set(input.serverId, input.sessionId ?? '');
203
+ let session: NewSessionResponse | LoadSessionResponse;
204
+ let sessionId: string;
205
+ if (input.sessionId) {
206
+ this.sessions.set(input.sessionId, input.handlers);
207
+ session = await this.agent.request<LoadSessionResponse>('session/load', { ...params, sessionId: input.sessionId });
208
+ sessionId = input.sessionId;
209
+ } else {
210
+ const created = await this.agent.request<NewSessionResponse>('session/new', params);
211
+ session = created;
212
+ sessionId = created.sessionId;
213
+ this.sessions.set(sessionId, input.handlers);
214
+ }
215
+ this.servers.set(input.serverId, sessionId);
216
+ return { sessionId, response: session };
217
+ }
218
+
219
+ detachSession(sessionId: string, serverId: string) {
220
+ this.sessions.delete(sessionId);
221
+ this.servers.delete(serverId);
222
+ }
223
+
224
+ /** Close the socket but keep this connection usable: the next `open()` reconnects and sessions `session/load` themselves. */
225
+ drop(reason = 'reconnect requested') {
226
+ const socket = this.socket;
227
+ this.markDropped(reason);
228
+ socket?.close();
229
+ }
230
+
231
+ async close() {
232
+ this.closed = true;
233
+ this.sessions.clear();
234
+ this.servers.clear();
235
+ this.connection = undefined;
236
+ this.socket?.close();
237
+ this.socket = undefined;
238
+ }
239
+ }