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,472 @@
1
+ // Per-Pi-session Grok turn state. Grok runs one long turn per user prompt on its own harness:
2
+ // native tools execute inside Grok and show up here as observations. Only calls to Pi-hosted
3
+ // tools are parked until Pi's loop returns a result. Events that arrive while no Pi stream is
4
+ // consuming are buffered.
5
+ import type { SessionNotification, PromptResponse, RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
6
+ import type { Tool, ToolResultMessage } from '@earendil-works/pi-ai';
7
+ import type { GrokModelConnection, McpToolDefinition, SdkCall } from './connection.ts';
8
+ import { capabilityGate, postEditContext, stopGate, classify, mcpServerOf, type GrokToolStamp, type HookRun, type HookReply } from './hooks.ts';
9
+ import type { HookSettings } from '../config.ts';
10
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { basename, extname, isAbsolute, join, resolve } from 'node:path';
12
+
13
+ export type TurnEvent =
14
+ | { kind: 'text'; delta: string }
15
+ | { kind: 'thought'; delta: string }
16
+ | { kind: 'toolcall'; toolCallId: string; name: string; arguments: Record<string, unknown> }
17
+ | { kind: 'complete'; response: PromptResponse; usage?: GrokTurnUsage }
18
+ | { kind: 'error'; error: Error };
19
+
20
+ /** Token accounting from Grok's `turn_completed` extension notification (per turn, all model calls summed). */
21
+ export type GrokTurnUsage = {
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ cachedReadTokens: number;
25
+ cacheCreationTokens: number;
26
+ reasoningTokens: number;
27
+ modelCalls: number;
28
+ /** Grok reports cost in USD ticks; 1e9 ticks per dollar by cross-check against SuperGrok rates. */
29
+ costUsd: number;
30
+ /** Context size after the turn (`_meta.totalTokens` on the prompt response). */
31
+ contextTokens?: number;
32
+ };
33
+ export function parseTurnUsage(raw: any): GrokTurnUsage | undefined {
34
+ if (!raw || typeof raw !== 'object') return undefined;
35
+ const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
36
+ return { inputTokens: n(raw.inputTokens), outputTokens: n(raw.outputTokens), cachedReadTokens: n(raw.cachedReadTokens), cacheCreationTokens: n(raw.cacheCreationTokens), reasoningTokens: n(raw.reasoningTokens), modelCalls: n(raw.modelCalls), costUsd: n(raw.costUsdTicks) / 1e9 };
37
+ }
38
+
39
+ /** One Grok-native tool call, executed on Grok's harness, as a structured record for Pi's session. */
40
+ export type GrokToolRecord = {
41
+ toolUseId: string;
42
+ tool: string;
43
+ input: unknown;
44
+ status: 'completed' | 'failed' | 'denied';
45
+ output?: string;
46
+ durationMs?: number;
47
+ denyReason?: string;
48
+ hookContext?: string;
49
+ /** Media file Pi shows and later turns can use: the project copy when `mediaDir` is set, else Grok's original. */
50
+ mediaPath?: string;
51
+ /** Grok's original file under ~/.grok/sessions/<url-encoded cwd>/..., kept for reference. */
52
+ sourcePath?: string;
53
+ };
54
+
55
+ type Parked = { resolve(result: unknown): void; reject(error: Error): void };
56
+
57
+ export class GrokModelSession {
58
+ grokSessionId?: string;
59
+ readonly serverId: string;
60
+ tools: Tool[] = [];
61
+ private readonly parked = new Map<string, Parked>();
62
+ private readonly buffer: TurnEvent[] = [];
63
+ private consumer?: (event: TurnEvent) => void;
64
+ private activePrompt?: Promise<PromptResponse>;
65
+ private toolSeq = 0;
66
+ private readonly connection: GrokModelConnection;
67
+ readonly piSessionId: string;
68
+ readonly cwd: string;
69
+ /** Grok's native permission prompts (file edits, shell) go here; default denies. */
70
+ permission: (request: RequestPermissionRequest) => Promise<RequestPermissionResponse> = async () => ({ outcome: { outcome: 'cancelled' } });
71
+ /** Grok's ask_user_question; default cancelled (the model is told the user did not answer). */
72
+ ask: (request: any) => Promise<Record<string, unknown>> = async () => ({ outcome: 'cancelled' });
73
+ /** Pi tool names present in the Pi session; the pre_tool_use gate mirrors them onto Grok's harness. */
74
+ piToolNames: string[] = [];
75
+ hookSettings: HookSettings = {};
76
+ /** Grok-side permission mode sent at session/new. */
77
+ grokMode: 'default' | 'auto' | 'yolo' = 'default';
78
+ /**
79
+ * Pi-side permission mode for Grok's native tools, applied at pre_tool_use (so it holds even where Grok's own
80
+ * rules would auto-allow): `auto` = capability mirror only; `readonly` = deny writes and shell regardless of Pi's
81
+ * tools; `ask` = mirror, then a Pi dialog for every write or shell call; `yolo` = mirror as if Pi had read, edit,
82
+ * write, and bash, with no Pi dialog. `denyGrokTools` and Grok's own permission prompts still apply in every
83
+ * mode. Headless Pi treats `ask` as `readonly`.
84
+ */
85
+ permissionMode: 'yolo' | 'auto' | 'ask' | 'readonly' = 'auto';
86
+ /** Dialog used by `ask` mode; set by the extension when Pi has a UI. */
87
+ askDialog?: (tool: string, input: unknown) => Promise<boolean>;
88
+ /** Directory for project copies of Grok media (relative to cwd, or absolute). Empty disables copying. */
89
+ mediaDir = '.pi/grok-images';
90
+ /** Hook decisions this session made, for evidence and tests. */
91
+ readonly hookLog: { event: string; tool?: string; decision?: string; reason?: string; context?: string }[] = [];
92
+ /** Receives one structured record per Grok-native tool call (from the hook pairs). */
93
+ onToolRecord?: (record: GrokToolRecord) => void;
94
+ private readonly nativeCalls = new Map<string, string>(); // toolCallId -> title
95
+ /** `_meta["x.ai/tool"]` from each tool_call update, keyed by toolCallId; the gate classifies by it. */
96
+ private readonly stamps = new Map<string, GrokToolStamp>();
97
+ /** Per-tool `_meta` from `_x.ai/mcp/list`, keyed by qualified `server__tool`. Fetched once per session on first need. */
98
+ private mcpToolMeta?: Map<string, unknown>;
99
+ private mcpToolMetaLoading?: Promise<void>;
100
+
101
+ private async loadMcpToolMeta() {
102
+ if (this.mcpToolMeta || !this.grokSessionId) return;
103
+ if (!this.mcpToolMetaLoading) this.mcpToolMetaLoading = (async () => {
104
+ const map = new Map<string, unknown>();
105
+ try {
106
+ const raw: any = await this.connection.agent.request('_x.ai/mcp/list', { sessionId: this.grokSessionId });
107
+ const body = raw?.result ?? raw;
108
+ for (const server of body?.servers ?? []) for (const tool of server?.session?.tools ?? []) if (tool?._meta) map.set(`${server.name}__${tool.name}`, tool._meta);
109
+ } catch { /* no catalog: fall back to server allowlist */ }
110
+ this.mcpToolMeta = map;
111
+ })();
112
+ await this.mcpToolMetaLoading;
113
+ }
114
+
115
+ constructor(connection: GrokModelConnection, piSessionId: string, cwd: string, serverId?: string) {
116
+ this.connection = connection;
117
+ this.piSessionId = piSessionId;
118
+ this.cwd = cwd;
119
+ this.serverId = serverId ?? `pi-${piSessionId}`;
120
+ }
121
+
122
+ get promptActive() { return !!this.activePrompt; }
123
+ get pendingToolCallIds() { return [...this.parked.keys()]; }
124
+
125
+ /** Set when a turn had to reconnect; the provider surfaces it once. */
126
+ reconnected?: string;
127
+ /** Connection generation this session was attached under; a newer generation means the socket dropped since. */
128
+ private attachedGeneration = -1;
129
+
130
+ async attach(rules: string | undefined) {
131
+ if (!this.connection.isOpen) await this.connection.open();
132
+ if (this.grokSessionId && this.attachedGeneration === this.connection.generation) return;
133
+ if (this.grokSessionId && this.attachedGeneration >= 0) {
134
+ // Socket dropped since the last attach (for example a gateway restart). session/load the same Grok session; history lives on the leader.
135
+ if (this.activePrompt) { this.activePrompt = undefined; this.rejectParked('Grok connection dropped; the turn was lost.'); }
136
+ this.reconnected = this.connection.lastDrop ?? 'reconnected';
137
+ }
138
+ const { sessionId } = await this.connection.attachSession({
139
+ sessionId: this.grokSessionId, cwd: this.cwd, serverId: this.serverId, serverName: 'pi', rules,
140
+ offerPiTools: this.tools.length > 0, grokMode: this.grokMode,
141
+ handlers: { onUpdate: (n) => this.onUpdate(n), onMcp: (m) => this.onMcp(m), onPermission: (r) => this.permission(r), onHookRun: (p, gate) => this.onHookRun(p, gate), onHookEvent: (p) => { void this.onHookRun(p); }, onQuestion: (q) => this.ask(q), onSessionExt: (u) => this.onSessionExt(u) },
142
+ });
143
+ this.grokSessionId = sessionId;
144
+ this.attachedGeneration = this.connection.generation;
145
+ }
146
+
147
+ detach() {
148
+ if (this.grokSessionId) this.connection.detachSession(this.grokSessionId, this.serverId);
149
+ for (const p of this.parked.values()) p.reject(new Error('Pi session detached.'));
150
+ this.parked.clear();
151
+ this.buffer.length = 0;
152
+ this.consumer = undefined;
153
+ this.activePrompt = undefined;
154
+ }
155
+
156
+ /** Usage from the latest `turn_completed`; consumed by the next `complete` event. Grok sends it just before the prompt response. */
157
+ private lastTurnUsage?: GrokTurnUsage;
158
+ /** Running totals for this Pi session, for `/grok debug`. */
159
+ readonly usageTotals = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, costUsd: 0, turns: 0 };
160
+ /** Grok's context size after the last completed prompt (`_meta.totalTokens`). */
161
+ lastContextTokens?: number;
162
+
163
+ private onSessionExt(update: any) {
164
+ if (update?.sessionUpdate !== 'turn_completed') return;
165
+ const usage = parseTurnUsage(update.usage);
166
+ if (!usage) return;
167
+ this.lastTurnUsage = usage;
168
+ this.usageTotals.inputTokens += usage.inputTokens; this.usageTotals.outputTokens += usage.outputTokens;
169
+ this.usageTotals.cachedReadTokens += usage.cachedReadTokens; this.usageTotals.costUsd += usage.costUsd; this.usageTotals.turns++;
170
+ }
171
+
172
+ /** Start a Grok turn. Completion, cancellation, and failure surface as TurnEvents. */
173
+ /** Increments per prompt. Events from an earlier prompt (e.g. a cancelled turn's late `complete`) are dropped, not replayed into the next turn. */
174
+ private promptSeq = 0;
175
+
176
+ startPrompt(text: string) {
177
+ if (this.activePrompt) throw new Error('A Grok prompt is already active for this session.');
178
+ this.lastTurnUsage = undefined;
179
+ this.buffer.length = 0; // anything buffered belongs to the previous prompt
180
+ const seq = ++this.promptSeq;
181
+ const promise = this.connection.agent.request<PromptResponse>('session/prompt', { sessionId: this.grokSessionId, prompt: [{ type: 'text', text }] });
182
+ this.activePrompt = promise;
183
+ return promise.then(
184
+ (response) => {
185
+ if (seq !== this.promptSeq) return; // superseded: a newer prompt owns the consumer
186
+ this.activePrompt = undefined;
187
+ const usage = this.lastTurnUsage; this.lastTurnUsage = undefined;
188
+ const contextTokens = (response as { _meta?: { totalTokens?: unknown } })._meta?.totalTokens;
189
+ if (typeof contextTokens === 'number') { this.lastContextTokens = contextTokens; if (usage) usage.contextTokens = contextTokens; }
190
+ this.emit({ kind: 'complete', response, usage });
191
+ return response;
192
+ },
193
+ (error) => { if (seq !== this.promptSeq) return undefined; this.activePrompt = undefined; this.emit({ kind: 'error', error: error instanceof Error ? error : new Error(String(error)) }); return undefined; },
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Pi aborted the turn. Ask Grok to cancel and stop treating the in-flight prompt as active, so the next Pi
199
+ * message starts a fresh prompt instead of being parked behind a turn that is ending.
200
+ */
201
+ abandonPrompt() {
202
+ if (!this.activePrompt) return;
203
+ void this.cancel();
204
+ this.activePrompt = undefined;
205
+ this.promptSeq++; // late completion of the cancelled prompt is dropped
206
+ this.buffer.length = 0;
207
+ }
208
+
209
+ async cancel() {
210
+ if (!this.grokSessionId || !this.activePrompt) return;
211
+ await this.connection.agent.notify('session/cancel', { sessionId: this.grokSessionId }).catch(() => {});
212
+ }
213
+
214
+ /** Grok's current session mode as last reported by `current_mode_update` (or set by us). */
215
+ mode: string = 'default';
216
+
217
+ /** Set a Grok session config option (for example `reasoning_effort`); Grok mirrors the change to every subscriber. */
218
+ async setConfigOption(configId: string, value: string) {
219
+ if (!this.grokSessionId) throw new Error('No Grok session yet. Send a message first.');
220
+ await this.connection.agent.request('session/set_config_option', { sessionId: this.grokSessionId, configId, value });
221
+ }
222
+
223
+ /** Grok reasoning effort last applied, so a repeated Pi thinking level does not resend it every turn. */
224
+ private appliedEffort?: string;
225
+ /** Apply Pi's thinking level as Grok's `reasoning_effort` when it changed. Grok accepts low, medium, high, and (except 4.5) xhigh. */
226
+ async applyEffort(level: string | undefined) {
227
+ if (!level) return;
228
+ const effort = ({ low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh' } as Record<string, string>)[level];
229
+ if (!effort || effort === this.appliedEffort) return;
230
+ await this.setConfigOption('reasoning_effort', effort);
231
+ this.appliedEffort = effort;
232
+ }
233
+
234
+ /** Forget the Grok session so the next Pi message creates a fresh one. Pi's own history is untouched. */
235
+ reset() {
236
+ if (this.grokSessionId) this.connection.detachSession(this.grokSessionId, this.serverId);
237
+ this.abandonPrompt();
238
+ this.rejectParked('Grok session reset.');
239
+ this.grokSessionId = undefined;
240
+ this.mode = 'default';
241
+ this.lastContextTokens = undefined;
242
+ }
243
+
244
+ /** Switch Grok's session mode (`plan` or `default`) through ACP `session/set_mode`. */
245
+ async setMode(modeId: 'plan' | 'default') {
246
+ if (!this.grokSessionId) throw new Error('No Grok session yet. Send a message first.');
247
+ await this.connection.agent.request('session/set_mode', { sessionId: this.grokSessionId, modeId });
248
+ this.mode = modeId;
249
+ }
250
+
251
+ /**
252
+ * Run one of Grok's own slash commands (`/goal status`, `/compact`, `/context`, `/session-info`) as a
253
+ * prompt outside Pi's model loop and return the text Grok streams back. Status-style commands
254
+ * resolve without a model sample.
255
+ */
256
+ async runCommand(text: string, timeoutMs = 120_000): Promise<{ text: string; stopReason: string }> {
257
+ if (!this.grokSessionId) throw new Error('No Grok session yet. Send a message first.');
258
+ if (this.activePrompt) throw new Error('Grok is busy with a turn. Wait for it to finish.');
259
+ let out = '';
260
+ let outcome: Extract<TurnEvent, { kind: 'complete' | 'error' }> | undefined;
261
+ const detach = this.consume((event) => { if (event.kind === 'text') out += event.delta; else if (event.kind === 'complete' || event.kind === 'error') outcome = event; });
262
+ let timer: ReturnType<typeof setTimeout> | undefined;
263
+ try {
264
+ // Same lifetime as a normal turn: startPrompt owns busy state, completion, and late-completion suppression.
265
+ const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Grok command timed out after ${timeoutMs / 1000}s.`)), timeoutMs); timer.unref?.(); });
266
+ await Promise.race([this.startPrompt(text), timeout]);
267
+ if (!outcome) throw new Error('Grok command ended without a completion.');
268
+ if (outcome.kind === 'error') throw outcome.error;
269
+ return { text: out.trim(), stopReason: outcome.response.stopReason };
270
+ } catch (error) {
271
+ this.abandonPrompt(); // timeout or failure: cancel on Grok and free the session, as a Pi abort does
272
+ throw error;
273
+ } finally { clearTimeout(timer); detach(); }
274
+ }
275
+
276
+ /** Feed Pi tool results back to Grok's parked tools/call requests. Returns ids that had no parked call. */
277
+ resolveToolResults(results: ToolResultMessage[]): ToolResultMessage[] {
278
+ const orphans: ToolResultMessage[] = [];
279
+ for (const result of results) {
280
+ const parked = this.parked.get(result.toolCallId);
281
+ if (!parked) { orphans.push(result); continue; }
282
+ this.parked.delete(result.toolCallId);
283
+ parked.resolve({ content: result.content.map((c) => c.type === 'text' ? { type: 'text', text: c.text } : { type: 'image', data: c.data, mimeType: c.mimeType }), isError: result.isError });
284
+ }
285
+ return orphans;
286
+ }
287
+
288
+ rejectParked(reason: string) {
289
+ for (const p of this.parked.values()) p.reject(new Error(reason));
290
+ this.parked.clear();
291
+ }
292
+
293
+ /** Attach a consumer; buffered events are flushed first. Returns a detach function. */
294
+ consume(consumer: (event: TurnEvent) => void) {
295
+ this.consumer = consumer;
296
+ const backlog = this.buffer.splice(0);
297
+ for (const event of backlog) consumer(event);
298
+ return () => { if (this.consumer === consumer) this.consumer = undefined; };
299
+ }
300
+
301
+ private emit(event: TurnEvent) {
302
+ if (this.consumer) this.consumer(event); else this.buffer.push(event);
303
+ }
304
+
305
+ private onUpdate(notification: SessionNotification) {
306
+ const update = notification.update as any;
307
+ switch (update?.sessionUpdate) {
308
+ case 'agent_message_chunk':
309
+ if (update.content?.type === 'text') this.emit({ kind: 'text', delta: update.content.text });
310
+ return;
311
+ case 'agent_thought_chunk':
312
+ if (update.content?.type === 'text') this.emit({ kind: 'thought', delta: update.content.text });
313
+ return;
314
+ case 'current_mode_update':
315
+ if (typeof update.currentModeId === 'string') this.mode = update.currentModeId;
316
+ return;
317
+ case 'tool_call': {
318
+ // A Grok-native tool starting on the Grok harness. Pi observes; it does not execute.
319
+ const title = String(update.title ?? update.kind ?? 'tool');
320
+ if (/^(pi__|mcp__pi__)/.test(title)) return; // Pi-hosted calls surface through sdk_call instead
321
+ this.nativeCalls.set(String(update.toolCallId), title);
322
+ const stamp = update._meta?.['x.ai/tool'];
323
+ if (stamp && typeof stamp === 'object') this.stamps.set(String(update.toolCallId), stamp as GrokToolStamp);
324
+ const input = update.rawInput ? ' ' + compact(update.rawInput) : '';
325
+ this.emit({ kind: 'thought', delta: `\n[grok ${title}]${input}\n` });
326
+ return;
327
+ }
328
+ case 'tool_call_update': {
329
+ const id = String(update.toolCallId);
330
+ const title = this.nativeCalls.get(id);
331
+ if (!title) return;
332
+ if (update.status === 'completed' || update.status === 'failed') {
333
+ this.nativeCalls.delete(id);
334
+ this.stamps.delete(id);
335
+ const out = Array.isArray(update.content) ? update.content.map((c: any) => c?.content?.text ?? '').join('') : '';
336
+ this.emit({ kind: 'thought', delta: `[grok ${title} ${update.status}]${out ? ' ' + compact(out) : ''}\n` });
337
+ }
338
+ return;
339
+ }
340
+ }
341
+ }
342
+
343
+ /** Blocking client hooks: gate native tools by Pi capability, annotate edits, and hold the stop. */
344
+ async onHookRun(payload: HookRun, gate?: { dialog(): void }): Promise<HookReply> {
345
+ try {
346
+ switch (payload.hookEventName) {
347
+ case 'pre_tool_use': {
348
+ const tool = payload.toolName ?? '';
349
+ const stamp = this.stamps.get(payload.toolUseId ?? '');
350
+ if (classify(tool, stamp) === 'mcp' || mcpServerOf(tool)) await this.loadMcpToolMeta();
351
+ const piTools = this.permissionMode === 'readonly' || (this.permissionMode === 'ask' && !this.askDialog) ? this.piToolNames.filter((n) => !['edit', 'write', 'bash'].includes(n))
352
+ : this.permissionMode === 'yolo' ? [...new Set([...this.piToolNames, 'read', 'edit', 'write', 'bash'])] : this.piToolNames;
353
+ let verdict = capabilityGate(piTools, this.hookSettings, (t) => this.mcpToolMeta?.get(t))(tool, stamp);
354
+ const kind = classify(tool, stamp);
355
+ const needsDialog = this.permissionMode === 'ask' && kind !== 'read' && kind !== 'other';
356
+ if (verdict.allow && needsDialog && this.askDialog) {
357
+ gate?.dialog(); // a human is deciding: the gateway waits the dialog window, not the policy window
358
+ const ok = await this.askDialog(tool, payload.toolInput);
359
+ if (!ok) verdict = { allow: false, reason: `The user declined ${tool}.` };
360
+ }
361
+ const reply: HookReply = verdict.allow ? { decision: 'continue' } : { decision: 'deny', reason: verdict.reason };
362
+ this.hookLog.push({ event: 'pre_tool_use', tool, decision: reply.decision, reason: reply.reason });
363
+ if (!verdict.allow) this.onToolRecord?.({ toolUseId: payload.toolUseId ?? '', tool, input: payload.toolInput, status: 'denied', denyReason: verdict.reason });
364
+ return reply;
365
+ }
366
+ case 'post_tool_use': {
367
+ const tool = payload.toolName ?? '';
368
+ let context: string | undefined;
369
+ if (classify(tool, this.stamps.get(payload.toolUseId ?? '')) === 'write') context = await postEditContext(payload.toolInput, payload.cwd || this.cwd, this.hookSettings);
370
+ this.hookLog.push({ event: 'post_tool_use', tool, decision: 'continue', context });
371
+ const source = mediaPath(payload.toolResult);
372
+ const media = source ? this.copyMedia(source) : undefined;
373
+ this.onToolRecord?.({ toolUseId: payload.toolUseId ?? '', tool, input: payload.toolInput, status: 'completed', output: media ?? resultText(payload.toolResult), durationMs: payload.durationMs, hookContext: context, mediaPath: media ?? source, sourcePath: source });
374
+ return context ? { decision: 'continue', additionalContext: context } : { decision: 'continue' };
375
+ }
376
+ case 'post_tool_use_failure': {
377
+ const tool = payload.toolName ?? '';
378
+ this.onToolRecord?.({ toolUseId: payload.toolUseId ?? '', tool, input: payload.toolInput, status: 'failed', output: resultText(payload.toolResult ?? (payload as any).error), durationMs: payload.durationMs });
379
+ return { decision: 'continue' };
380
+ }
381
+ case 'stop': {
382
+ const reply = await stopGate(payload, this.hookSettings);
383
+ this.hookLog.push({ event: 'stop', decision: reply.decision, reason: reply.reason });
384
+ return reply;
385
+ }
386
+ default:
387
+ return { decision: 'continue' };
388
+ }
389
+ } catch (error) {
390
+ this.hookLog.push({ event: payload.hookEventName, decision: 'continue', reason: `hook error: ${error instanceof Error ? error.message : String(error)}` });
391
+ return { decision: 'continue' }; // fail open, like Grok's own hooks
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Copy a Grok media file into the project so Pi shows a readable path instead of
397
+ * ~/.grok/sessions/%2Fhome%2F.../images/1.jpg. Returns the copy's path, or undefined when copying is off or fails.
398
+ */
399
+ private copyMedia(source: string): string | undefined {
400
+ if (!this.mediaDir) return undefined;
401
+ try {
402
+ const dir = isAbsolute(this.mediaDir) ? this.mediaDir : resolve(this.cwd, this.mediaDir);
403
+ mkdirSync(dir, { recursive: true });
404
+ const ignore = join(dir, '.gitignore');
405
+ if (!existsSync(ignore)) writeFileSync(ignore, '*\n');
406
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2');
407
+ const short = (this.grokSessionId ?? 'session').slice(-6);
408
+ const name = `${stamp}-${short}-${basename(source, extname(source))}${extname(source).toLowerCase()}`;
409
+ const target = join(dir, name);
410
+ if (!existsSync(target)) copyFileSync(source, target);
411
+ return target;
412
+ } catch { return undefined; }
413
+ }
414
+
415
+ private onMcp(message: SdkCall): Promise<unknown> {
416
+ switch (message.method) {
417
+ case 'initialize':
418
+ return Promise.resolve({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'pi', version: '0.1.0' } });
419
+ case 'tools/list':
420
+ return Promise.resolve({ tools: this.tools.map(toMcpTool) });
421
+ case 'tools/call': {
422
+ const name = String(message.params?.name ?? '');
423
+ const args = (message.params?.arguments ?? {}) as Record<string, unknown>;
424
+ if (!this.tools.some((t) => t.name === name)) return Promise.reject(new Error(`Unknown Pi tool ${name}`));
425
+ const toolCallId = `grok_${this.serverId}_${++this.toolSeq}`;
426
+ return new Promise((resolve, reject) => {
427
+ this.parked.set(toolCallId, { resolve, reject });
428
+ this.emit({ kind: 'toolcall', toolCallId, name, arguments: args });
429
+ });
430
+ }
431
+ default:
432
+ return Promise.reject(new Error(`Unsupported MCP method ${message.method}`));
433
+ }
434
+ }
435
+ }
436
+
437
+ /** Saved media path from a Grok media tool result (`{ type: "ImageGen", path, filename, session_folder }` and kin). */
438
+ export function mediaPath(value: unknown): string | undefined {
439
+ const v = (value ?? {}) as Record<string, any>;
440
+ return typeof v.path === 'string' && /^(ImageGen|ImageEdit|ImageToVideo|ReferenceToVideo|VideoGen)$/.test(String(v.type ?? '')) ? v.path : undefined;
441
+ }
442
+
443
+ /** Best-effort plain text from a Grok tool result envelope (e.g. ReadFile.FileContent.raw_output, or a string). */
444
+ export function resultText(value: unknown, limit = 8000): string | undefined {
445
+ if (value == null) return undefined;
446
+ if (typeof value === 'string') return value.slice(0, limit);
447
+ const media = mediaPath(value);
448
+ if (media) return media;
449
+ const v = value as Record<string, any>;
450
+ const nested = v.FileContent?.raw_output ?? v.FileContent?.content ?? v.output ?? v.stdout ?? v.text ?? v.content ?? v.message;
451
+ if (typeof nested === 'string') return nested.slice(0, limit);
452
+ if (Array.isArray(nested)) return nested.map((c) => (typeof c === 'string' ? c : c?.text ?? '')).join('').slice(0, limit) || undefined;
453
+ return JSON.stringify(value).slice(0, limit);
454
+ }
455
+
456
+ function compact(value: unknown, limit = 400): string {
457
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
458
+ return text.length > limit ? text.slice(0, limit) + '…' : text;
459
+ }
460
+
461
+ const PI_READ_ONLY_TOOLS = new Set(['read', 'grep', 'find', 'ls', 'symbol_search', 'module_report', 'read_symbol', 'read_enclosing', 'lens_diagnostics', 'project_report', 'effective_config']);
462
+
463
+ /**
464
+ * Pi tool -> MCP tool definition. Read-only Pi tools carry the marker in `_meta` (Grok forwards `_meta`, not
465
+ * `annotations`), so a read-only Pi session can still let Grok call them. `annotations` is sent too for clients that keep it.
466
+ */
467
+ export function toMcpTool(tool: Tool): McpToolDefinition & { annotations?: Record<string, unknown>; _meta?: Record<string, unknown> } {
468
+ const readOnly = PI_READ_ONLY_TOOLS.has(tool.name) || (tool as { readOnly?: boolean }).readOnly === true;
469
+ const def: McpToolDefinition & { annotations?: Record<string, unknown>; _meta?: Record<string, unknown> } = { name: tool.name, description: tool.description, inputSchema: JSON.parse(JSON.stringify(tool.parameters)) };
470
+ if (readOnly) { def.annotations = { readOnlyHint: true }; def._meta = { readOnlyHint: true }; }
471
+ return def;
472
+ }
@@ -0,0 +1,50 @@
1
+ // Mid-turn steering for Grok model sessions. Pi distinguishes Enter mid-turn (steer: inject into the
2
+ // running turn) from Alt+Enter (followUp: queue for after). A steered message reaches Grok through
3
+ // Grok's own `x.ai/interject`, which the running turn drains at its next safe point; it must NOT go
4
+ // through Pi's steer queue, or Pi would deliver it as a new prompt after the turn (follow-up semantics).
5
+ import type { ImageContent } from '@earendil-works/pi-ai';
6
+ import type { InputEvent, InputEventResult } from '@earendil-works/pi-coding-agent';
7
+ import { spillImageFile } from './provider.ts';
8
+
9
+ export interface SteerDeps {
10
+ /** A Grok session exists to steer into (a turn may or may not be running; Grok queues safely). */
11
+ hasGrokSession(): boolean;
12
+ /** Send the text to the running Grok turn. */
13
+ interject(text: string): Promise<unknown>;
14
+ /** Persist a record so the steered text is visible in the transcript. */
15
+ record(text: string): void;
16
+ /** Tell the user something went wrong (optional). */
17
+ notify?(text: string): void;
18
+ }
19
+
20
+ /** Render steered input (text plus any attached images) the way Grok receives it. Exported for tests. */
21
+ export function steerText(text: string, images?: ImageContent[]): string {
22
+ const parts = [text.trim()];
23
+ for (const image of images ?? []) {
24
+ try { parts.push(`\n[attached image: ${spillImageFile(image.data, image.mimeType)} — read this file to view it]\n`); }
25
+ catch { parts.push('\n[image: could not be attached]\n'); }
26
+ }
27
+ return parts.filter(Boolean).join('\n');
28
+ }
29
+
30
+ /**
31
+ * Pi `input` handler. Takes over only the steer case for Grok sessions: anything else (idle, followUp,
32
+ * extension commands, empty input) flows through Pi untouched.
33
+ */
34
+ export function createSteerHandler(deps: SteerDeps): (event: InputEvent) => Promise<InputEventResult | void> {
35
+ return async (event) => {
36
+ if (event.streamingBehavior !== 'steer') return;
37
+ if (event.text.startsWith('/')) return; // extension commands and prompt templates: Pi's queue expands them
38
+ if (!event.text.trim() && !(event.images?.length)) return;
39
+ if (!deps.hasGrokSession()) return;
40
+ const text = steerText(event.text, event.images);
41
+ try {
42
+ await deps.interject(text);
43
+ deps.record(text);
44
+ return { action: 'handled' };
45
+ } catch (error) {
46
+ deps.notify?.(`Could not steer Grok's turn (${error instanceof Error ? error.message : String(error)}); queued normally.`);
47
+ return { action: 'continue' };
48
+ }
49
+ };
50
+ }