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.
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { createServer } from 'node:http';
6
+ import { createConnection } from 'node:net';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { WebSocketServer, WebSocket } from 'ws';
10
+ import { agentDir, defaultSecretFile, readConfig } from "../src/config.js";
11
+ await mkdir(agentDir, { recursive: true, mode: 0o700 });
12
+ if (!process.env.GROK_AGENT_SECRET) {
13
+ try {
14
+ await writeFile(defaultSecretFile, randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 });
15
+ }
16
+ catch (error) {
17
+ if (error.code !== 'EEXIST')
18
+ throw error;
19
+ }
20
+ }
21
+ const config = await readConfig();
22
+ const MCP_PATH = '/mcp/';
23
+ const GATEWAY_ID_PREFIX = 'pi-gw:';
24
+ const { ackMs: ACK_MS, policyMs: POLICY_MS, checkBudgetMs: CHECK_BUDGET_MS, dialogMs: DIALOG_MS } = config.guard;
25
+ function gateKey(message) {
26
+ const p = message?.params ?? {};
27
+ if (message?.method === '_x.ai/hooks/run') {
28
+ const event = String(p.hookEventName ?? '');
29
+ if (event === 'stop')
30
+ return `stop:${p.sessionId ?? p.session_id ?? ''}`;
31
+ return p.toolUseId ? `${event}:${p.toolUseId}` : undefined;
32
+ }
33
+ if (message?.method === 'session/request_permission')
34
+ return p.toolCall?.toolCallId ? `perm:${p.toolCall.toolCallId}` : undefined;
35
+ if (message?.method === '_x.ai/ask_user_question')
36
+ return `ask:${p.toolCallId ?? p.tool_call_id ?? ''}`;
37
+ return undefined;
38
+ }
39
+ const guardStats = { gateDenied: 0, feedbackContinued: 0, permissionRejected: 0, lateAnswersDropped: 0 };
40
+ class ReverseRequestGuard {
41
+ gates = new Map();
42
+ byKey = new Map();
43
+ reply;
44
+ constructor(reply) { this.reply = reply; }
45
+ get open() { let n = 0; for (const g of this.gates.values())
46
+ if (g.state === 'open')
47
+ n++; return n; }
48
+ watch(message) {
49
+ if (typeof message?.method !== 'string' || !('id' in message) || message.id === null)
50
+ return;
51
+ const id = JSON.stringify(message.id);
52
+ const reply = (result) => this.reply({ jsonrpc: '2.0', id: message.id, result });
53
+ let gate;
54
+ if (message.method === '_x.ai/hooks/run') {
55
+ const event = String(message.params?.hookEventName ?? '');
56
+ if (event === 'pre_tool_use')
57
+ gate = { kind: 'gate', state: 'open', answer: (why) => { guardStats.gateDenied++; reply({ decision: 'deny', reason: `Denied by the Pi gateway: ${why}.` }); } };
58
+ else if (event === 'stop' || event === 'post_tool_use')
59
+ gate = { kind: 'feedback', state: 'open', answer: () => { guardStats.feedbackContinued++; reply({ decision: 'continue' }); } };
60
+ }
61
+ else if (message.method === 'session/request_permission') {
62
+ const options = message.params?.options ?? [];
63
+ const reject = options.find((o) => o?.kind === 'reject_once') ?? options.find((o) => String(o?.kind).startsWith('reject'));
64
+ gate = { kind: 'permission', state: 'open', answer: () => { guardStats.permissionRejected++; reply(reject ? { outcome: { outcome: 'selected', optionId: reject.optionId } } : { outcome: { outcome: 'cancelled' } }); } };
65
+ }
66
+ else if (message.method === '_x.ai/ask_user_question') {
67
+ gate = { kind: 'permission', state: 'open', answer: () => { guardStats.permissionRejected++; reply({ outcome: 'cancelled' }); } };
68
+ }
69
+ if (!gate)
70
+ return;
71
+ gate.key = gateKey(message);
72
+ this.gates.set(id, gate);
73
+ if (gate.key)
74
+ this.byKey.set(gate.key, id);
75
+ this.arm(id, gate, ACK_MS, `Pi did not acknowledge within ${ACK_MS / 1000}s`);
76
+ }
77
+ ack(params) {
78
+ const id = this.byKey.get(String(params?.key ?? ''));
79
+ const gate = id ? this.gates.get(id) : undefined;
80
+ if (!id || !gate || gate.state !== 'open')
81
+ return;
82
+ if (params?.dialog)
83
+ this.arm(id, gate, DIALOG_MS, `no answer to the dialog within ${DIALOG_MS / 60_000} min`);
84
+ else if (params?.check)
85
+ this.arm(id, gate, CHECK_BUDGET_MS, `check exceeded ${CHECK_BUDGET_MS / 1000}s`);
86
+ else
87
+ this.arm(id, gate, POLICY_MS, `Pi policy did not answer within ${POLICY_MS / 1000}s`);
88
+ }
89
+ settle(message) {
90
+ if (!message || !('id' in message) || !('result' in message || 'error' in message))
91
+ return 'forward';
92
+ const id = JSON.stringify(message.id);
93
+ const gate = this.gates.get(id);
94
+ if (!gate)
95
+ return 'forward';
96
+ this.forget(id, gate);
97
+ if (gate.state === 'open')
98
+ return 'forward';
99
+ guardStats.lateAnswersDropped++;
100
+ return 'drop';
101
+ }
102
+ close(why) {
103
+ for (const [id, gate] of this.gates)
104
+ if (gate.state === 'open') {
105
+ clearTimeout(gate.timer);
106
+ gate.state = 'answered';
107
+ gate.answer(why);
108
+ this.forget(id, gate);
109
+ }
110
+ }
111
+ arm(id, gate, ms, why) {
112
+ clearTimeout(gate.timer);
113
+ gate.timer = setTimeout(() => { if (this.gates.get(id) === gate && gate.state === 'open') {
114
+ gate.state = 'answered';
115
+ gate.answer(why);
116
+ } }, ms);
117
+ }
118
+ forget(id, gate) {
119
+ clearTimeout(gate.timer);
120
+ this.gates.delete(id);
121
+ if (gate.key && this.byKey.get(gate.key) === id)
122
+ this.byKey.delete(gate.key);
123
+ }
124
+ }
125
+ const relays = new Map();
126
+ let gatewayIds = 0;
127
+ function mcpTokenFrom(value) {
128
+ try {
129
+ const path = new URL(String(value)).pathname;
130
+ if (path.startsWith(MCP_PATH))
131
+ return path.slice(MCP_PATH.length);
132
+ }
133
+ catch { }
134
+ return undefined;
135
+ }
136
+ function registerMcpTokens(relay, message) {
137
+ if (message?.method !== 'session/new' && message?.method !== 'session/load')
138
+ return;
139
+ for (const server of message.params?.mcpServers ?? []) {
140
+ const token = mcpTokenFrom(server?.url);
141
+ if (!token)
142
+ continue;
143
+ const previous = relays.get(token);
144
+ if (previous && previous !== relay)
145
+ previous.tokens.delete(token);
146
+ relays.set(token, relay);
147
+ relay.tokens.add(token);
148
+ }
149
+ }
150
+ function endJson(res, status, body, headers = {}) {
151
+ const text = body === undefined ? '' : JSON.stringify(body);
152
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text), ...headers });
153
+ res.end(text);
154
+ }
155
+ function handleMcpHttp(req, res) {
156
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
157
+ if (!path.startsWith(MCP_PATH)) {
158
+ endJson(res, 404, { error: 'not found' });
159
+ return;
160
+ }
161
+ const token = path.slice(MCP_PATH.length);
162
+ if (req.method !== 'POST') {
163
+ res.writeHead(405, { allow: 'POST' });
164
+ res.end();
165
+ return;
166
+ }
167
+ const relay = relays.get(token);
168
+ if (!relay || relay.socket.readyState !== WebSocket.OPEN) {
169
+ endJson(res, 404, { error: 'unknown MCP token' });
170
+ return;
171
+ }
172
+ const chunks = [];
173
+ let size = 0;
174
+ req.on('data', (c) => { size += c.length; if (size > 16 * 1024 * 1024) {
175
+ req.destroy();
176
+ return;
177
+ } chunks.push(c); });
178
+ req.on('end', () => {
179
+ let message;
180
+ try {
181
+ message = JSON.parse(Buffer.concat(chunks).toString('utf8'));
182
+ }
183
+ catch {
184
+ endJson(res, 400, { error: 'invalid JSON' });
185
+ return;
186
+ }
187
+ if (!message || typeof message !== 'object' || typeof message.method !== 'string') {
188
+ endJson(res, 400, { error: 'expected a JSON-RPC message' });
189
+ return;
190
+ }
191
+ if (!('id' in message) || message.id === null) {
192
+ res.writeHead(202);
193
+ res.end();
194
+ return;
195
+ }
196
+ const id = `${GATEWAY_ID_PREFIX}${++gatewayIds}`;
197
+ relay.pending.set(id, res);
198
+ res.on('close', () => relay.pending.delete(id));
199
+ relay.socket.send(JSON.stringify({ jsonrpc: '2.0', id, method: '_x.ai/mcp/sdk_call', params: { serverId: token, message } }), (error) => {
200
+ if (error && relay.pending.delete(id))
201
+ endJson(res, 502, { jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'Pi connection send failed' } });
202
+ });
203
+ });
204
+ }
205
+ function answerFromPi(relay, message) {
206
+ if (typeof message?.id !== 'string' || !message.id.startsWith(GATEWAY_ID_PREFIX) || !('result' in message || 'error' in message))
207
+ return false;
208
+ const res = relay.pending.get(message.id);
209
+ relay.pending.delete(message.id);
210
+ if (!res)
211
+ return true;
212
+ if ('error' in message)
213
+ endJson(res, 200, { jsonrpc: '2.0', id: null, error: message.error });
214
+ else
215
+ endJson(res, 200, message.result);
216
+ return true;
217
+ }
218
+ const url = new URL(config.url);
219
+ if (url.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.pathname !== '/ws') {
220
+ throw new Error('The local launcher requires a loopback ws:// endpoint ending in /ws.');
221
+ }
222
+ const leaderSocket = process.env.PI_GROK_LEADER_SOCKET || join(homedir(), '.grok', 'pi', 'leader.sock');
223
+ await mkdir(join(homedir(), '.grok', 'pi'), { recursive: true, mode: 0o700 });
224
+ const grokBinary = process.env.PI_GROK_BINARY || 'grok';
225
+ const proxies = new Set();
226
+ let gateway;
227
+ let httpServer;
228
+ let stopping = false;
229
+ let leader;
230
+ let leaderRestarts = 0;
231
+ const leaderLock = leaderSocket.replace(/\.sock$/, '') + '.lock';
232
+ function socketAnswers() {
233
+ return new Promise((resolve) => { const s = createConnection(leaderSocket); s.once('connect', () => { s.destroy(); resolve(true); }); s.once('error', () => { s.destroy(); resolve(false); }); });
234
+ }
235
+ async function existingLeaderPid() {
236
+ try {
237
+ const pid = Number((await readFile(leaderLock, 'utf8')).trim());
238
+ if (!Number.isInteger(pid) || pid <= 0)
239
+ return undefined;
240
+ try {
241
+ process.kill(pid, 0);
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ return (await socketAnswers()) ? pid : undefined;
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }
252
+ function leaderAlive() { return !!leader; }
253
+ function stopLeader() {
254
+ if (!leader)
255
+ return;
256
+ if ('adopted' in leader) {
257
+ try {
258
+ process.kill(leader.pid, 'SIGTERM');
259
+ }
260
+ catch { }
261
+ leader = undefined;
262
+ return;
263
+ }
264
+ stopChild(leader);
265
+ }
266
+ function watchAdopted(pid) {
267
+ const timer = setInterval(() => {
268
+ if (leader && 'adopted' in leader && leader.pid === pid) {
269
+ try {
270
+ process.kill(pid, 0);
271
+ return;
272
+ }
273
+ catch {
274
+ leader = undefined;
275
+ clearInterval(timer);
276
+ if (!stopping)
277
+ void onLeaderGone(`adopted leader ${pid} exited`);
278
+ }
279
+ }
280
+ else
281
+ clearInterval(timer);
282
+ }, 1000);
283
+ timer.unref();
284
+ }
285
+ const LEADER_ARGS = ['--permission-mode', 'default', 'agent', 'leader', '--no-exit-on-disconnect', '--relay-on-demand', '--no-auto-update', '--leader-socket', leaderSocket];
286
+ function waitForSocket(child, timeoutMs = 30_000) {
287
+ return new Promise((resolve, reject) => {
288
+ const timeout = setTimeout(() => { cleanup(); reject(new Error('Grok leader socket startup timed out.')); }, timeoutMs);
289
+ const failed = () => { cleanup(); reject(new Error('Grok leader exited before startup.')); };
290
+ const interval = setInterval(() => {
291
+ const socket = createConnection(leaderSocket);
292
+ socket.once('connect', () => { socket.destroy(); cleanup(); resolve(); });
293
+ socket.once('error', () => socket.destroy());
294
+ }, 100);
295
+ const cleanup = () => { clearTimeout(timeout); clearInterval(interval); child.off('exit', failed); child.off('error', failed); };
296
+ child.once('exit', failed);
297
+ child.once('error', failed);
298
+ if (stopping)
299
+ failed();
300
+ });
301
+ }
302
+ async function startLeader() {
303
+ const existing = await existingLeaderPid();
304
+ if (existing) {
305
+ leader = { pid: existing, adopted: true };
306
+ watchAdopted(existing);
307
+ console.error(`Adopted leader ${existing} already on ${leaderSocket}.`);
308
+ return;
309
+ }
310
+ try {
311
+ const pid = Number((await readFile(leaderLock, 'utf8')).trim());
312
+ process.kill(pid, 0);
313
+ }
314
+ catch {
315
+ await rm(leaderSocket, { force: true });
316
+ }
317
+ const child = spawn(grokBinary, LEADER_ARGS, { stdio: ['ignore', 'inherit', 'inherit'] });
318
+ leader = child;
319
+ child.once('exit', (code, sig) => {
320
+ if (leader !== child)
321
+ return;
322
+ leader = undefined;
323
+ if (stopping)
324
+ return;
325
+ void onLeaderGone(`exited (${code ?? sig})`);
326
+ });
327
+ child.once('error', (error) => { if (leader === child) {
328
+ leader = undefined;
329
+ if (!stopping)
330
+ void onLeaderGone(error.message);
331
+ } });
332
+ await waitForSocket(child);
333
+ }
334
+ async function onLeaderGone(why) {
335
+ leaderRestarts++;
336
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(leaderRestarts - 1, 5));
337
+ console.error(`Grok leader ${why}; dropping ${proxies.size} bridge(s), ensuring a leader in ${delay / 1000}s (restart #${leaderRestarts}).`);
338
+ for (const proxy of proxies)
339
+ stopChild(proxy);
340
+ await new Promise((r) => setTimeout(r, delay));
341
+ if (stopping)
342
+ return;
343
+ try {
344
+ await startLeader();
345
+ console.error(leader && 'adopted' in leader ? 'Grok leader adopted.' : 'Grok leader respawned.');
346
+ if (leaderRestarts > 0)
347
+ setTimeout(() => { leaderRestarts = Math.max(0, leaderRestarts - 1); }, 120_000).unref();
348
+ }
349
+ catch (error) {
350
+ console.error(`Grok leader respawn failed: ${error instanceof Error ? error.message : String(error)}`);
351
+ void onLeaderGone('respawn failed');
352
+ }
353
+ }
354
+ function stopChild(child) {
355
+ if (child.exitCode !== null || child.signalCode !== null)
356
+ return;
357
+ child.kill('SIGTERM');
358
+ const kill = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
359
+ child.kill('SIGKILL'); }, 2500);
360
+ kill.unref();
361
+ child.once('exit', () => clearTimeout(kill));
362
+ }
363
+ function shutdown(error) {
364
+ if (stopping)
365
+ return;
366
+ stopping = true;
367
+ if (error) {
368
+ console.error(error.message);
369
+ process.exitCode = 1;
370
+ }
371
+ for (const socket of gateway?.clients ?? [])
372
+ socket.terminate();
373
+ gateway?.close();
374
+ httpServer?.close();
375
+ for (const proxy of proxies)
376
+ stopChild(proxy);
377
+ stopLeader();
378
+ }
379
+ for (const signal of ['SIGTERM', 'SIGINT'])
380
+ process.on(signal, () => shutdown());
381
+ try {
382
+ httpServer = createServer(handleMcpHttp);
383
+ httpServer.requestTimeout = 0;
384
+ httpServer.headersTimeout = 60_000;
385
+ gateway = new WebSocketServer({
386
+ server: httpServer, path: '/ws', maxPayload: 16 * 1024 * 1024,
387
+ verifyClient: ({ req }) => req.headers.authorization === `Bearer ${config.secret}`,
388
+ });
389
+ gateway.on('error', (error) => shutdown(error));
390
+ gateway.on('connection', async (socket) => {
391
+ for (let i = 0; !leaderAlive() && !stopping && i < 100; i++)
392
+ await new Promise((r) => setTimeout(r, 300));
393
+ if (!leaderAlive() || stopping) {
394
+ socket.close(1013, 'Grok leader unavailable');
395
+ return;
396
+ }
397
+ const proxy = spawn(grokBinary, ['--permission-mode', 'default', 'agent', '--leader', 'stdio', '--leader-socket', leaderSocket], { stdio: ['pipe', 'pipe', 'inherit'] });
398
+ proxies.add(proxy);
399
+ const relay = { socket, pending: new Map(), tokens: new Set(), guard: new ReverseRequestGuard((message) => proxy.stdin.write(JSON.stringify(message) + '\n')) };
400
+ let buffer = '';
401
+ const fail = () => { if (socket.readyState === WebSocket.OPEN)
402
+ socket.close(1011, 'Grok leader client disconnected'); stopChild(proxy); };
403
+ proxy.stdout.setEncoding('utf8');
404
+ proxy.stdout.on('data', (chunk) => {
405
+ buffer += chunk;
406
+ if (buffer.length > 16 * 1024 * 1024 || socket.bufferedAmount > 16 * 1024 * 1024) {
407
+ fail();
408
+ return;
409
+ }
410
+ for (;;) {
411
+ const end = buffer.indexOf('\n');
412
+ if (end < 0)
413
+ break;
414
+ const line = buffer.slice(0, end).replace(/\r$/, '');
415
+ buffer = buffer.slice(end + 1);
416
+ if (!line)
417
+ continue;
418
+ try {
419
+ relay.guard.watch(JSON.parse(line));
420
+ }
421
+ catch { }
422
+ if (socket.readyState === WebSocket.OPEN)
423
+ socket.send(line, (error) => { if (error)
424
+ fail(); });
425
+ }
426
+ });
427
+ socket.on('message', (data) => {
428
+ const text = data.toString().replace(/[\r\n]+$/, '');
429
+ if (!text || text === 'ping')
430
+ return;
431
+ let message;
432
+ try {
433
+ message = JSON.parse(text);
434
+ }
435
+ catch {
436
+ message = undefined;
437
+ }
438
+ if (message && answerFromPi(relay, message))
439
+ return;
440
+ if (message?.method === 'pi/gate-ack') {
441
+ relay.guard.ack(message.params);
442
+ return;
443
+ }
444
+ if (message) {
445
+ if (relay.guard.settle(message) === 'drop')
446
+ return;
447
+ registerMcpTokens(relay, message);
448
+ }
449
+ if (!proxy.stdin.write(text + '\n'))
450
+ socket.pause();
451
+ });
452
+ proxy.stdin.on('drain', () => socket.resume());
453
+ proxy.stdin.on('error', fail);
454
+ proxy.on('error', fail);
455
+ proxy.once('exit', () => { proxies.delete(proxy); if (socket.readyState === WebSocket.OPEN)
456
+ socket.close(1011, 'Grok leader client exited'); });
457
+ socket.on('error', () => stopChild(proxy));
458
+ socket.once('close', () => {
459
+ const hadGates = relay.guard.open > 0;
460
+ relay.guard.close('Pi connection closed');
461
+ if (hadGates)
462
+ setTimeout(() => stopChild(proxy), 500);
463
+ else
464
+ stopChild(proxy);
465
+ for (const token of relay.tokens)
466
+ if (relays.get(token) === relay)
467
+ relays.delete(token);
468
+ for (const [id, res] of relay.pending) {
469
+ relay.pending.delete(id);
470
+ endJson(res, 502, { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Pi connection closed' } });
471
+ }
472
+ });
473
+ });
474
+ await new Promise((resolve, reject) => {
475
+ httpServer.once('error', reject);
476
+ httpServer.listen(Number(url.port || 80), url.hostname === '[::1]' ? '::1' : url.hostname, () => { httpServer.off('error', reject); resolve(); });
477
+ });
478
+ httpServer.on('error', (error) => shutdown(error));
479
+ await startLeader();
480
+ if (stopping)
481
+ throw new Error('Grok gateway startup cancelled.');
482
+ console.log(`Grok WebSocket ACP ready at ${url}; MCP relay at ${url.origin.replace(/^ws/, 'http')}${MCP_PATH}<token>; leader socket ${leaderSocket}; binary ${grokBinary}`);
483
+ }
484
+ catch (error) {
485
+ shutdown(error instanceof Error ? error : new Error(String(error)));
486
+ }
@@ -0,0 +1,82 @@
1
+ import WebSocket from 'ws';
2
+ export function validateEndpoint(value) {
3
+ const url = new URL(value);
4
+ if (!['ws:', 'wss:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
5
+ throw new Error('Use a ws:// or wss:// endpoint without credentials, query, or fragment.');
6
+ }
7
+ if (url.protocol === 'ws:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
8
+ throw new Error('Use wss:// for a non-loopback server.');
9
+ }
10
+ return url.toString();
11
+ }
12
+ export async function openSocket(options, signal) {
13
+ signal?.throwIfAborted();
14
+ const url = validateEndpoint(options.url);
15
+ if (!options.secret.trim())
16
+ throw new Error('Grok WebSocket secret is missing.');
17
+ const socket = new WebSocket(url, {
18
+ headers: { Authorization: `Bearer ${options.secret}` },
19
+ handshakeTimeout: 10_000,
20
+ maxPayload: 16 * 1024 * 1024,
21
+ followRedirects: false,
22
+ });
23
+ let input;
24
+ let ended = false;
25
+ const end = (error) => {
26
+ if (ended)
27
+ return;
28
+ ended = true;
29
+ if (error)
30
+ input.error(error);
31
+ else
32
+ input.close();
33
+ };
34
+ const readable = new ReadableStream({
35
+ start(controller) { input = controller; },
36
+ cancel() { ended = true; socket.terminate(); }
37
+ });
38
+ socket.on('message', (data) => {
39
+ if (ended)
40
+ return;
41
+ try {
42
+ input.enqueue(JSON.parse(data.toString()));
43
+ }
44
+ catch {
45
+ end(new Error('Invalid JSON from Grok WebSocket.'));
46
+ socket.terminate();
47
+ }
48
+ });
49
+ let resolveClosed;
50
+ const closed = new Promise((resolve) => { resolveClosed = resolve; });
51
+ socket.on('close', () => { end(); resolveClosed(); });
52
+ socket.on('error', () => end(new Error('Grok WebSocket connection failed. Check the endpoint, server, and secret.')));
53
+ await new Promise((resolve, reject) => {
54
+ const cleanup = () => { signal?.removeEventListener('abort', abort); socket.off('open', opened); socket.off('error', failed); socket.off('close', closed); };
55
+ const opened = () => { cleanup(); resolve(); };
56
+ const failed = () => { cleanup(); socket.terminate(); reject(new Error('Grok WebSocket handshake failed. Check the endpoint, server, and secret.')); };
57
+ const closed = () => { cleanup(); reject(new Error('Grok WebSocket closed during connection.')); };
58
+ const abort = () => { cleanup(); socket.terminate(); reject(new Error('Grok connection cancelled.')); };
59
+ socket.once('open', opened);
60
+ socket.once('error', failed);
61
+ socket.once('close', closed);
62
+ signal?.addEventListener('abort', abort, { once: true });
63
+ if (signal?.aborted)
64
+ abort();
65
+ });
66
+ return {
67
+ stream: {
68
+ readable,
69
+ writable: new WritableStream({
70
+ write(message) {
71
+ return new Promise((resolve, reject) => {
72
+ socket.send(JSON.stringify(message), (error) => error ? reject(new Error('Grok WebSocket send failed.')) : resolve());
73
+ });
74
+ },
75
+ close() { socket.close(); },
76
+ abort() { socket.terminate(); },
77
+ }),
78
+ },
79
+ close() { socket.terminate(); },
80
+ closed,
81
+ };
82
+ }
@@ -0,0 +1,89 @@
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.js";
5
+ export const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
6
+ export const configPath = join(agentDir, 'grok-ws.json');
7
+ export const defaultSecretFile = join(agentDir, 'grok-ws.secret');
8
+ export const PI_CORE_TOOLS = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
9
+ export const GROK_HOOK_CAP_MS = 600_000;
10
+ export const GATE_REGISTRATION_MS = 30_000;
11
+ const GUARD_DEFAULTS = { ackMs: 5_000, policyMs: 15_000, checkBudgetMs: 590_000, dialogMs: 600_000 };
12
+ export function resolveGuard(settings, env = process.env) {
13
+ const pick = (key, envName) => {
14
+ const raw = env[envName] ?? settings?.[key];
15
+ if (raw === undefined || raw === '')
16
+ return GUARD_DEFAULTS[key];
17
+ const n = Number(raw);
18
+ if (!Number.isFinite(n) || n <= 0)
19
+ throw new Error(`guard.${key} must be a positive number of milliseconds (got ${String(raw)}).`);
20
+ return Math.floor(n);
21
+ };
22
+ 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') };
23
+ if (guard.ackMs >= GATE_REGISTRATION_MS || guard.policyMs >= GATE_REGISTRATION_MS)
24
+ throw new Error('guard.ackMs and guard.policyMs must be below the pre_tool_use registration deadline (' + GATE_REGISTRATION_MS + ' ms).');
25
+ if (guard.checkBudgetMs >= GROK_HOOK_CAP_MS)
26
+ throw new Error('guard.checkBudgetMs must be below Grok\'s hook cap (' + GROK_HOOK_CAP_MS + ' ms).');
27
+ if (guard.ackMs > guard.policyMs)
28
+ throw new Error('guard.ackMs must not exceed guard.policyMs.');
29
+ return guard;
30
+ }
31
+ export async function readSecretFile(secretFile) {
32
+ try {
33
+ return (await readFile(secretFile, 'utf8')).trim();
34
+ }
35
+ catch (error) {
36
+ if (error.code === 'ENOENT')
37
+ return undefined;
38
+ throw error;
39
+ }
40
+ }
41
+ export async function readConfig() {
42
+ let settings = {};
43
+ try {
44
+ settings = JSON.parse(await readFile(configPath, 'utf8'));
45
+ }
46
+ catch (error) {
47
+ if (error.code !== 'ENOENT')
48
+ throw error;
49
+ }
50
+ const url = validateEndpoint(process.env.GROK_ACP_URL || settings.url || 'ws://127.0.0.1:2419/ws');
51
+ const configuredFile = settings.secretFile || defaultSecretFile;
52
+ const secretFile = configuredFile.startsWith('~/') ? join(homedir(), configuredFile.slice(2)) : configuredFile;
53
+ if (!isAbsolute(secretFile))
54
+ throw new Error('grok-ws secretFile must be absolute or start with ~/.');
55
+ const secret = process.env.GROK_AGENT_SECRET || (await readSecretFile(secretFile)) || '';
56
+ const piTools = process.env.PI_GROK_PI_TOOLS ? parsePolicy(process.env.PI_GROK_PI_TOOLS) : settings.piTools ?? 'extensions';
57
+ const hooks = { ...settings.hooks };
58
+ if (process.env.PI_GROK_STOP_CHECK)
59
+ hooks.stopCheck = process.env.PI_GROK_STOP_CHECK;
60
+ if (process.env.PI_GROK_POST_EDIT_CHECK)
61
+ hooks.postEditCheck = process.env.PI_GROK_POST_EDIT_CHECK;
62
+ if (process.env.PI_GROK_DENY_TOOLS)
63
+ hooks.denyGrokTools = process.env.PI_GROK_DENY_TOOLS.split(',').map((s) => s.trim()).filter(Boolean);
64
+ const headlessPermissions = process.env.PI_GROK_HEADLESS_PERMISSIONS ?? settings.headlessPermissions ?? 'dialog';
65
+ if (!['dialog', 'deny', 'reads', 'allow'].includes(headlessPermissions))
66
+ throw new Error(`headlessPermissions must be dialog, deny, reads, or allow (got ${headlessPermissions}).`);
67
+ const guard = resolveGuard(settings.guard);
68
+ const mediaDir = process.env.PI_GROK_MEDIA_DIR ?? settings.mediaDir ?? '.pi/grok-images';
69
+ const grokMode = process.env.PI_GROK_GROK_MODE ?? settings.grokMode ?? 'default';
70
+ if (!['default', 'auto', 'yolo'].includes(grokMode))
71
+ throw new Error(`grokMode must be default, auto, or yolo (got ${grokMode}).`);
72
+ const autoStartGateway = process.env.PI_GROK_AUTOSTART ? !['0', 'false', 'no', 'off'].includes(process.env.PI_GROK_AUTOSTART.toLowerCase()) : settings.autoStartGateway ?? true;
73
+ return { url, secret, secretFile, piTools, hooks, headlessPermissions, guard, mediaDir, grokMode, autoStartGateway };
74
+ }
75
+ function parsePolicy(value) {
76
+ if (value === 'none' || value === 'extensions' || value === 'all')
77
+ return value;
78
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
79
+ }
80
+ export function selectPiTools(tools, policy) {
81
+ if (policy === 'none')
82
+ return [];
83
+ if (policy === 'all')
84
+ return tools;
85
+ if (policy === 'extensions')
86
+ return tools.filter((t) => !PI_CORE_TOOLS.has(t.name));
87
+ const allowed = new Set(policy);
88
+ return tools.filter((t) => allowed.has(t.name));
89
+ }