claude-simple 0.2.7 → 0.2.9

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.
Files changed (59) hide show
  1. package/README.md +0 -0
  2. package/dist/app.0.2.9.min.js +1080 -0
  3. package/dist/index.html +2 -2
  4. package/dist/{style.0.2.7.min.css → style.0.2.9.min.css} +1 -1
  5. package/gateway.js +0 -0
  6. package/package.json +1 -1
  7. package/public/addon-fit.min.js +0 -0
  8. package/public/agent-panel.js +0 -0
  9. package/public/api.js +2 -0
  10. package/public/app.js +0 -0
  11. package/public/base.css +0 -0
  12. package/public/cache.js +0 -0
  13. package/public/chat.js +42 -8
  14. package/public/files.js +0 -0
  15. package/public/git.js +0 -0
  16. package/public/icons/icon-180.png +0 -0
  17. package/public/icons/icon-192.png +0 -0
  18. package/public/icons/icon-512.png +0 -0
  19. package/public/icons/icon-maskable-512.png +0 -0
  20. package/public/index.html +0 -0
  21. package/public/lib.js +0 -0
  22. package/public/manifest.webmanifest +0 -0
  23. package/public/mobile.js +0 -0
  24. package/public/notifications.js +215 -0
  25. package/public/offline.html +0 -0
  26. package/public/pwa.js +0 -0
  27. package/public/router.js +0 -0
  28. package/public/settings.js +0 -0
  29. package/public/shell/boot.js +0 -0
  30. package/public/shell/dashboard.js +46 -26
  31. package/public/shell/hub.js +0 -0
  32. package/public/shell/layout.js +2 -3
  33. package/public/shell/notes.js +0 -0
  34. package/public/shell/session-context.js +0 -0
  35. package/public/shell/session-list.js +22 -18
  36. package/public/shell/session-nav.js +18 -1
  37. package/public/shell/templates.js +18 -0
  38. package/public/shell/util.js +0 -0
  39. package/public/shell.js +24 -6
  40. package/public/state.js +64 -7
  41. package/public/style.css +0 -0
  42. package/public/sw.js +1 -1
  43. package/public/terminal.js +0 -0
  44. package/public/wake-lock.js +0 -0
  45. package/public/xterm.min.css +0 -0
  46. package/public/xterm.min.js +0 -0
  47. package/src/agents/claude.js +0 -0
  48. package/src/agents/codex.js +0 -0
  49. package/src/agents/grok.js +415 -271
  50. package/src/agents/meta-agent.js +0 -0
  51. package/src/agents/models.js +2 -1
  52. package/src/agents/sessions.js +0 -0
  53. package/src/agents/stream-batch.js +0 -0
  54. package/src/app.js +12 -2
  55. package/src/auto-update.js +0 -0
  56. package/src/runtime/approvals.js +0 -0
  57. package/src/runtime/outbox.js +0 -0
  58. package/src/runtime/protocol.js +0 -0
  59. package/dist/app.0.2.7.min.js +0 -1039
@@ -1,327 +1,471 @@
1
1
  import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
2
4
  import readline from 'readline';
3
5
  import crypto from 'crypto';
4
- import { createDeltaBatcher } from './stream-batch.js';
5
6
 
6
- /**
7
- * Run Grok CLI headlessly with streaming-json over WebSocket (not HTTP SSE).
8
- *
9
- * New turn:
10
- * grok -p <prompt> --output-format streaming-json --cwd <dir> ...
11
- * Resume:
12
- * grok --resume <sessionId> -p <prompt> --output-format streaming-json ...
13
- *
14
- * Stream events (line-delimited JSON):
15
- * { type: "thought", data: "..." }
16
- * { type: "text", data: "..." }
17
- * { type: "tool_*", ... } // when CLI emits them
18
- * { type: "end", sessionId, usage, ... }
19
- *
20
- * We forward deltas immediately so the UI updates live instead of dumping at end.
21
- */
7
+ // Persistent ACP (JSON-RPC over stdio). Advertise terminal+fs so Grok routes
8
+ // shell/file tools through client RPCs instead of its own broken terminal path.
9
+ const clients = new Map();
10
+ const queues = new Map();
11
+ const queueAliases = new Map();
22
12
 
23
- function mapPermission(permissionMode, allowBypass) {
24
- if (allowBypass || permissionMode === 'bypassPermissions') return 'bypassPermissions';
25
- if (permissionMode == null) return 'bypassPermissions';
26
- if (permissionMode === 'default') return null;
27
- return permissionMode;
13
+ function profileKey(options) {
14
+ return `${options.model || ''}\u001f${options.effort || ''}`;
28
15
  }
29
16
 
30
- function buildArgs(command, options) {
31
- const args = [];
32
-
33
- if (options.sessionId) {
34
- args.push('--resume', options.sessionId);
35
- }
36
-
37
- // -p = headless single-turn (required for non-TTY streaming-json)
38
- args.push('-p', command || '');
39
- args.push('--output-format', 'streaming-json');
40
-
41
- if (options.cwd) args.push('--cwd', options.cwd);
42
- if (options.model) args.push('--model', options.model);
43
- if (options.effort) args.push('--reasoning-effort', options.effort);
44
-
45
- const perm = mapPermission(options.permissionMode, options.allowDangerouslySkipPermissions);
46
- if (perm) args.push('--permission-mode', perm);
47
- if (options.allowDangerouslySkipPermissions || options.permissionMode === 'bypassPermissions') {
48
- args.push('--always-approve');
49
- }
50
-
51
- if (options.newSessionId && !options.sessionId) {
52
- args.push('--session-id', options.newSessionId);
17
+ function envFromParams(envList) {
18
+ const env = { ...process.env };
19
+ if (!Array.isArray(envList)) return env;
20
+ for (const item of envList) {
21
+ if (!item || typeof item.name !== 'string') continue;
22
+ env[item.name] = item.value == null ? '' : String(item.value);
53
23
  }
54
-
55
- return args;
24
+ return env;
56
25
  }
57
26
 
58
- function parseMaybeJson(v) {
59
- if (v == null) return {};
60
- if (typeof v === 'object') return v;
61
- if (typeof v === 'string') {
62
- try { return JSON.parse(v); } catch { return { input: v }; }
27
+ class TerminalHost {
28
+ constructor() {
29
+ this.terminals = new Map();
63
30
  }
64
- return { value: v };
65
- }
66
-
67
- export function runGrok(command, options, send, { cliPath, activeSessions }) {
68
- return new Promise((resolve) => {
69
- const sessionKey = options.sessionId || crypto.randomUUID();
70
- const bin = cliPath || 'grok';
71
- const args = buildArgs(command, options);
72
31
 
73
- const env = { ...process.env };
74
- env.CI = env.CI || '1';
75
- // Reduce pipe buffering if the runtime honors it
76
- env.PYTHONUNBUFFERED = '1';
32
+ create(params = {}) {
33
+ const terminalId = `term_${crypto.randomUUID()}`;
34
+ const command = String(params.command || '');
35
+ const args = Array.isArray(params.args) ? params.args.map(String) : [];
36
+ const cwd = params.cwd || process.cwd();
37
+ const limit = Number.isFinite(params.outputByteLimit) && params.outputByteLimit > 0
38
+ ? params.outputByteLimit
39
+ : 1024 * 1024;
40
+ const env = envFromParams(params.env);
77
41
 
78
42
  let child;
79
43
  try {
80
- child = spawn(bin, args, {
81
- cwd: options.cwd || process.cwd(),
44
+ child = spawn(command, args, {
45
+ cwd,
82
46
  env,
83
47
  stdio: ['ignore', 'pipe', 'pipe'],
48
+ shell: args.length === 0,
84
49
  });
85
- } catch (e) {
86
- send({ type: 'error', message: `Failed to start grok: ${e.message}`, agent: 'grok' });
87
- resolve();
88
- return;
50
+ } catch (error) {
51
+ const err = new Error(error.message || 'Failed to start terminal');
52
+ err.code = -32000;
53
+ throw err;
89
54
  }
90
55
 
91
- // Unbuffer stdout as much as possible
92
- if (child.stdout?.setEncoding) child.stdout.setEncoding('utf8');
93
-
94
- const entry = { kind: 'grok', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
95
- activeSessions.set(sessionKey, entry);
96
-
97
- let sessionId = options.sessionId || null;
98
- let aborted = false;
99
- let stderr = '';
100
- let gotEnd = false;
101
- let streamOpen = false; // live assistant bubble on the client
102
- let thoughtOpen = false;
103
- let textChars = 0;
104
- let thoughtChars = 0;
105
-
106
- const textBatch = createDeltaBatcher(send, 'assistant_delta', { agent: 'grok' }, { maxWait: 48, maxSize: 480 });
107
- const thoughtBatch = createDeltaBatcher(send, 'thought_delta', { agent: 'grok' }, { maxWait: 140, maxSize: 600 });
108
-
109
- child.stderr.on('data', d => { stderr += d.toString(); });
110
-
111
- // Use 'readable' + line split for lower latency than waiting for large chunks
112
- const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
113
-
114
- function ensureStream() {
115
- if (!streamOpen) {
116
- send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() });
117
- streamOpen = true;
118
- }
119
- }
56
+ const entry = {
57
+ id: terminalId,
58
+ child,
59
+ output: '',
60
+ truncated: false,
61
+ limit,
62
+ exitCode: null,
63
+ signal: null,
64
+ exited: false,
65
+ waiters: [],
66
+ };
120
67
 
121
- function endStream() {
122
- textBatch.flush();
123
- if (streamOpen) {
124
- send({ type: 'assistant_stream_end', agent: 'grok' });
125
- streamOpen = false;
68
+ const append = chunk => {
69
+ entry.output += chunk;
70
+ if (Buffer.byteLength(entry.output, 'utf8') > entry.limit) {
71
+ // Truncate from the start at a UTF-8-safe boundary.
72
+ let buf = Buffer.from(entry.output, 'utf8');
73
+ buf = buf.subarray(buf.length - entry.limit);
74
+ while (buf.length && (buf[0] & 0xc0) === 0x80) buf = buf.subarray(1);
75
+ entry.output = buf.toString('utf8');
76
+ entry.truncated = true;
126
77
  }
127
- }
78
+ };
128
79
 
129
- function flushThought() {
130
- if (!thoughtBatch.pending()) return;
131
- if (!thoughtOpen) {
132
- send({ type: 'thought_stream_start', agent: 'grok' });
133
- thoughtOpen = true;
80
+ child.stdout?.setEncoding('utf8');
81
+ child.stderr?.setEncoding('utf8');
82
+ child.stdout?.on('data', append);
83
+ child.stderr?.on('data', append);
84
+ child.on('error', err => {
85
+ append(`\n[terminal error] ${err.message}\n`);
86
+ if (!entry.exited) {
87
+ entry.exited = true;
88
+ entry.exitCode = 1;
89
+ entry.signal = null;
90
+ for (const resolve of entry.waiters) resolve({ exitCode: 1, signal: null });
91
+ entry.waiters = [];
134
92
  }
135
- const n = thoughtBatch.pending();
136
- thoughtBatch.flush();
137
- thoughtChars += n;
138
- }
93
+ });
94
+ child.on('close', (code, signal) => {
95
+ entry.exited = true;
96
+ entry.exitCode = code;
97
+ entry.signal = signal || null;
98
+ for (const resolve of entry.waiters) resolve({ exitCode: code, signal: signal || null });
99
+ entry.waiters = [];
100
+ });
139
101
 
140
- function emitTool(raw) {
141
- // Finalize any open text/thought before tool card
142
- flushThought();
143
- if (thoughtOpen) {
144
- send({ type: 'thought_stream_end', agent: 'grok' });
145
- thoughtOpen = false;
146
- }
147
- endStream();
102
+ this.terminals.set(terminalId, entry);
103
+ return { terminalId };
104
+ }
148
105
 
149
- const name = raw.name || raw.tool || raw.toolName || raw.tool_name
150
- || raw.title || raw.data?.name || 'tool';
151
- let input = raw.input ?? raw.arguments ?? raw.args ?? raw.rawInput
152
- ?? raw.data?.input ?? raw.data?.arguments ?? raw.data ?? {};
153
- input = parseMaybeJson(input);
106
+ get(terminalId) {
107
+ const entry = this.terminals.get(terminalId);
108
+ if (!entry) {
109
+ const err = new Error(`Unknown terminal: ${terminalId}`);
110
+ err.code = -32000;
111
+ throw err;
112
+ }
113
+ return entry;
114
+ }
154
115
 
155
- send({
156
- type: 'assistant',
157
- message: { content: [{ type: 'tool_use', name, input }] },
158
- agent: 'grok',
159
- ts: Date.now(),
160
- });
116
+ output(terminalId) {
117
+ const entry = this.get(terminalId);
118
+ const result = {
119
+ output: entry.output,
120
+ truncated: entry.truncated,
121
+ };
122
+ if (entry.exited) {
123
+ result.exitStatus = { exitCode: entry.exitCode, signal: entry.signal };
161
124
  }
125
+ return result;
126
+ }
162
127
 
163
- rl.on('line', (line) => {
164
- if (!line.trim()) return;
165
- let raw;
166
- try { raw = JSON.parse(line); } catch {
167
- // Non-JSON progress line — surface lightly
168
- send({ type: 'status', message: line.slice(0, 200), agent: 'grok' });
169
- return;
170
- }
128
+ waitForExit(terminalId) {
129
+ const entry = this.get(terminalId);
130
+ if (entry.exited) return Promise.resolve({ exitCode: entry.exitCode, signal: entry.signal });
131
+ return new Promise(resolve => entry.waiters.push(resolve));
132
+ }
171
133
 
172
- const t = String(raw.type || '').toLowerCase();
134
+ kill(terminalId) {
135
+ const entry = this.get(terminalId);
136
+ if (!entry.exited && entry.child?.pid) {
137
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
138
+ }
139
+ return {};
140
+ }
173
141
 
174
- // ── Text tokens (batched ~50ms) ──────────────────────────────────────
175
- if ((t === 'text' || t === 'message' || t === 'output_text') && raw.data != null) {
176
- flushThought();
177
- if (thoughtOpen) {
178
- send({ type: 'thought_stream_end', agent: 'grok' });
179
- thoughtOpen = false;
142
+ release(terminalId) {
143
+ const entry = this.terminals.get(terminalId);
144
+ if (!entry) return {};
145
+ if (!entry.exited && entry.child?.pid) {
146
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
147
+ setTimeout(() => {
148
+ if (!entry.exited && entry.child?.pid) {
149
+ try { entry.child.kill('SIGKILL'); } catch { /* already gone */ }
180
150
  }
181
- const chunk = String(raw.data);
182
- if (!chunk) return;
183
- ensureStream();
184
- textChars += chunk.length;
185
- textBatch.push(chunk);
186
- return;
187
- }
151
+ }, 800).unref?.();
152
+ }
153
+ this.terminals.delete(terminalId);
154
+ return {};
155
+ }
156
+ }
188
157
 
189
- // ── Thinking tokens (batched stream) ────────────────────────────────
190
- if ((t === 'thought' || t === 'thinking' || t === 'reasoning') && raw.data != null) {
191
- if (!thoughtOpen) {
192
- send({ type: 'thought_stream_start', agent: 'grok' });
193
- thoughtOpen = true;
194
- }
195
- thoughtBatch.push(String(raw.data));
196
- return;
158
+ class GrokACPClient {
159
+ constructor({ cliPath, model, effort }) {
160
+ const args = ['agent', '--always-approve'];
161
+ if (model) args.push('--model', model);
162
+ if (effort) args.push('--reasoning-effort', effort);
163
+ args.push('stdio');
164
+ this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
165
+ this.pending = new Map();
166
+ this.listeners = new Map();
167
+ // High ids avoid colliding with agent-originated request ids (often 0..N).
168
+ this.nextID = 100000;
169
+ this.terminals = new TerminalHost();
170
+ this.ready = this.initialize();
171
+ this.child.stderr.on('data', d => console.warn(`[grok acp] ${String(d).trim()}`));
172
+ this.child.on('close', () => {
173
+ for (const { reject } of this.pending.values()) reject(new Error('Grok ACP process stopped'));
174
+ this.pending.clear();
175
+ for (const fn of this.listeners.values()) {
176
+ fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
197
177
  }
178
+ this.listeners.clear();
179
+ });
180
+ const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
181
+ rl.on('line', line => this.receive(line));
182
+ }
198
183
 
199
- // ── Tools ───────────────────────────────────────────────────────────
200
- if (
201
- t === 'tool_use' || t === 'tool_call' || t === 'tool_start'
202
- || t === 'tool' || t === 'function_call' || t === 'tool_call_start'
203
- || t.startsWith('tool_')
204
- ) {
205
- // Avoid treating tool_result as a new call
206
- if (t === 'tool_result' || t === 'tool_call_update' || t === 'tool_end' || t === 'tool_call_end') {
207
- return;
208
- }
209
- emitTool(raw);
210
- return;
211
- }
184
+ async initialize() {
185
+ const init = await this.request('initialize', {
186
+ protocolVersion: 1,
187
+ clientCapabilities: {
188
+ fs: { readTextFile: true, writeTextFile: true },
189
+ terminal: true,
190
+ },
191
+ clientInfo: { name: 'claude-simple-ui', version: '0.2.9' },
192
+ });
193
+ const methods = new Set((init.authMethods || []).map(x => x?.id));
194
+ const methodId = process.env.XAI_API_KEY && methods.has('xai.api_key')
195
+ ? 'xai.api_key'
196
+ : (methods.has('cached_token') ? 'cached_token' : null);
197
+ if (methodId) await this.request('authenticate', { methodId, _meta: { headless: true } });
198
+ return init;
199
+ }
212
200
 
213
- // ── Session id early (some builds emit before end) ───────────────────
214
- if ((t === 'session' || t === 'session_started') && (raw.sessionId || raw.session_id)) {
215
- sessionId = raw.sessionId || raw.session_id;
216
- entry.sessionId = sessionId;
217
- activeSessions.set(sessionId, entry);
218
- if (!options.sessionId) {
219
- send({ type: 'session-created', sessionId, agent: 'grok' });
201
+ async handleClientRequest(msg) {
202
+ const method = msg.method;
203
+ const params = msg.params || {};
204
+ try {
205
+ let result = {};
206
+ if (method === 'fs/read_text_file') {
207
+ const filePath = String(params.path || '');
208
+ const content = await fs.promises.readFile(filePath, 'utf8');
209
+ const limit = Number.isFinite(params.limit) ? params.limit : null;
210
+ const line = Number.isFinite(params.line) ? params.line : null;
211
+ if (line != null || limit != null) {
212
+ const lines = content.split('\n');
213
+ const start = Math.max(0, (line || 1) - 1);
214
+ const end = limit != null ? start + limit : lines.length;
215
+ result = { content: lines.slice(start, end).join('\n') };
216
+ } else {
217
+ result = { content };
220
218
  }
219
+ } else if (method === 'fs/write_text_file') {
220
+ const filePath = String(params.path || '');
221
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
222
+ await fs.promises.writeFile(filePath, params.content ?? '', 'utf8');
223
+ result = {};
224
+ } else if (method === 'terminal/create') {
225
+ result = this.terminals.create(params);
226
+ } else if (method === 'terminal/output') {
227
+ result = this.terminals.output(params.terminalId);
228
+ } else if (method === 'terminal/wait_for_exit') {
229
+ result = await this.terminals.waitForExit(params.terminalId);
230
+ } else if (method === 'terminal/kill') {
231
+ result = this.terminals.kill(params.terminalId);
232
+ } else if (method === 'terminal/release') {
233
+ result = this.terminals.release(params.terminalId);
234
+ } else {
235
+ this.write({
236
+ jsonrpc: '2.0',
237
+ id: msg.id,
238
+ error: { code: -32601, message: `Unsupported client method: ${method}` },
239
+ });
221
240
  return;
222
241
  }
242
+ this.write({ jsonrpc: '2.0', id: msg.id, result });
243
+ } catch (error) {
244
+ this.write({
245
+ jsonrpc: '2.0',
246
+ id: msg.id,
247
+ error: { code: error.code || -32000, message: error.message || 'Client request failed' },
248
+ });
249
+ }
250
+ }
223
251
 
224
- // ── End of turn ─────────────────────────────────────────────────────
225
- if (t === 'end' || t === 'result' || t === 'done') {
226
- gotEnd = true;
227
- flushThought();
228
- if (thoughtOpen) {
229
- send({ type: 'thought_stream_end', agent: 'grok' });
230
- thoughtOpen = false;
231
- }
232
- endStream();
252
+ receive(line) {
253
+ let msg;
254
+ try { msg = JSON.parse(line); } catch { return; }
233
255
 
234
- if (raw.sessionId || raw.session_id) {
235
- sessionId = raw.sessionId || raw.session_id;
236
- entry.sessionId = sessionId;
237
- activeSessions.set(sessionId, entry);
238
- if (!options.sessionId) {
239
- send({ type: 'session-created', sessionId, agent: 'grok' });
240
- }
241
- }
256
+ // Agent→client requests always carry a method. Check before pending so
257
+ // overlapping numeric ids cannot steal a client RPC as our response.
258
+ if (msg.id != null && msg.method) {
259
+ void this.handleClientRequest(msg);
260
+ return;
261
+ }
242
262
 
243
- // Unlock UI immediately — process may linger after the turn ends.
244
- entry.busy = false;
263
+ if (msg.id != null && this.pending.has(msg.id)) {
264
+ const pending = this.pending.get(msg.id);
265
+ this.pending.delete(msg.id);
266
+ if (msg.error) pending.reject(new Error(msg.error.message || 'Grok ACP request failed'));
267
+ else pending.resolve(msg.result || {});
268
+ return;
269
+ }
245
270
 
246
- const u = raw.usage || {};
247
- const costTicks = raw.total_cost_usd_ticks ?? u.cost_usd_ticks ?? u.costUsdTicks;
248
- const costUSD = raw.total_cost_usd ?? u.cost_usd ?? u.costUSD
249
- ?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
271
+ if (msg.method === 'session/update') {
272
+ const sid = msg.params?.sessionId;
273
+ const fn = sid && this.listeners.get(sid);
274
+ if (fn) fn(msg.params?.update || {});
275
+ }
276
+ }
250
277
 
251
- const sid = sessionId || sessionKey;
252
- send({
253
- type: 'result',
254
- is_error: false,
255
- usage: {
256
- inputTokens: u.input_tokens || u.inputTokens || 0,
257
- outputTokens: u.output_tokens || u.outputTokens || 0,
258
- cacheReadInputTokens: u.cache_read_input_tokens || u.cachedReadTokens || 0,
259
- totalTokens: u.total_tokens || u.totalTokens
260
- || ((u.input_tokens || u.inputTokens || 0) + (u.output_tokens || u.outputTokens || 0)),
261
- reasoningTokens: u.reasoning_tokens || u.reasoningTokens || 0,
262
- costUSD,
263
- },
264
- sessionId: sid,
265
- agent: 'grok',
266
- stats: { textChars, thoughtChars },
267
- });
268
- // Early complete so client can send next turn without waiting for process exit.
269
- send({ type: 'complete', exitCode: 0, sessionId: sid, agent: 'grok' });
270
- return;
271
- }
278
+ write(data) {
279
+ if (!this.child.stdin?.writable) throw new Error('Grok ACP stdin is unavailable');
280
+ this.child.stdin.write(JSON.stringify(data) + '\n');
281
+ }
272
282
 
273
- if (t === 'error') {
274
- entry.busy = false;
275
- send({ type: 'error', message: raw.message || raw.data || 'grok error', agent: 'grok' });
276
- }
283
+ request(method, params) {
284
+ const id = this.nextID++;
285
+ return new Promise((resolve, reject) => {
286
+ this.pending.set(id, { resolve, reject });
287
+ try { this.write({ jsonrpc: '2.0', id, method, params }); }
288
+ catch (e) { this.pending.delete(id); reject(e); }
277
289
  });
290
+ }
278
291
 
279
- child.on('close', (code) => {
280
- flushThought();
281
- if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
282
- endStream();
283
- rl.close();
284
- entry.busy = false;
285
- activeSessions.delete(sessionKey);
286
- if (sessionId) activeSessions.delete(sessionId);
287
-
288
- // Skip duplicate complete if turn already finished via streaming-json "end".
289
- if (gotEnd && !aborted) {
290
- resolve();
291
- return;
292
- }
293
-
294
- if (aborted) {
295
- send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'grok' });
296
- } else if (code !== 0 && code != null && !gotEnd) {
297
- const errMsg = stderr.trim().split('\n').filter(l => !/stdin is not a terminal/i.test(l)).join('\n')
298
- || `grok exited with code ${code}`;
299
- send({ type: 'error', message: errMsg, agent: 'grok' });
300
- send({ type: 'complete', exitCode: code, sessionId: sessionId || sessionKey, agent: 'grok' });
301
- } else {
302
- if (sessionId && !options.sessionId && !gotEnd) {
303
- send({ type: 'session-created', sessionId, agent: 'grok' });
304
- }
305
- send({ type: 'complete', exitCode: code ?? 0, sessionId: sessionId || sessionKey, agent: 'grok' });
292
+ async ensureSession(options) {
293
+ await this.ready;
294
+ if (options.sessionId) {
295
+ try {
296
+ await this.request('session/load', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
297
+ } catch {
298
+ await this.request('session/resume', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
306
299
  }
307
- resolve();
300
+ return options.sessionId;
301
+ }
302
+ const result = await this.request('session/new', {
303
+ cwd: options.cwd || process.cwd(),
304
+ mcpServers: [],
305
+ _meta: { yoloMode: true },
308
306
  });
307
+ if (!result.sessionId) throw new Error('Grok ACP did not return a session id');
308
+ return result.sessionId;
309
+ }
310
+ }
309
311
 
310
- child.on('error', (e) => {
311
- entry.busy = false;
312
- activeSessions.delete(sessionKey);
313
- if (sessionId) activeSessions.delete(sessionId);
314
- send({ type: 'error', message: e.message, agent: 'grok' });
315
- resolve();
316
- });
312
+ function clientFor(options, cliPath) {
313
+ const key = profileKey(options);
314
+ let client = clients.get(key);
315
+ if (!client || client.child.exitCode != null || client.child.signalCode != null) {
316
+ client = new GrokACPClient({ cliPath, model: options.model, effort: options.effort });
317
+ clients.set(key, client);
318
+ }
319
+ return client;
320
+ }
321
+
322
+ function emitUpdate(update, send, state) {
323
+ const kind = update?.sessionUpdate;
324
+ if (kind === 'agent_message_chunk' && update.content?.text) {
325
+ if (state.thoughtOpen) {
326
+ send({ type: 'thought_stream_end', agent: 'grok' });
327
+ state.thoughtOpen = false;
328
+ }
329
+ if (!state.textOpen) {
330
+ send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() });
331
+ state.textOpen = true;
332
+ }
333
+ send({ type: 'assistant_delta', data: update.content.text, agent: 'grok' });
334
+ } else if (kind === 'agent_thought_chunk' && update.content?.text) {
335
+ if (!state.thoughtOpen) {
336
+ send({ type: 'thought_stream_start', agent: 'grok' });
337
+ state.thoughtOpen = true;
338
+ }
339
+ send({ type: 'thought_delta', data: update.content.text, agent: 'grok' });
340
+ } else if (kind === 'tool_call' || kind === 'tool_call_update') {
341
+ if (state.textOpen) {
342
+ send({ type: 'assistant_stream_end', agent: 'grok' });
343
+ state.textOpen = false;
344
+ }
345
+ if (state.thoughtOpen) {
346
+ send({ type: 'thought_stream_end', agent: 'grok' });
347
+ state.thoughtOpen = false;
348
+ }
349
+ if (kind === 'tool_call') {
350
+ send({
351
+ type: 'assistant',
352
+ agent: 'grok',
353
+ message: {
354
+ content: [{
355
+ type: 'tool_use',
356
+ name: update.title || update.kind || 'tool',
357
+ input: update.rawInput || update.input || {},
358
+ }],
359
+ },
360
+ });
361
+ }
362
+ }
363
+ }
317
364
 
365
+ async function runOne(item, { cliPath, activeSessions, queueKey }) {
366
+ const { command, options, send } = item;
367
+ const effort = options.effort || 'low';
368
+ const client = clientFor({ ...options, effort }, cliPath);
369
+ const localKey = options.sessionId || crypto.randomUUID();
370
+ const entry = {
371
+ kind: 'grok-acp',
372
+ startTime: Date.now(),
373
+ busy: true,
374
+ key: localKey,
375
+ sessionId: options.sessionId || null,
376
+ };
377
+ activeSessions.set(localKey, entry);
378
+ const state = { textOpen: false, thoughtOpen: false };
379
+ let sessionId = options.sessionId;
380
+ try {
381
+ sessionId = await client.ensureSession(options);
382
+ queueAliases.set(sessionId, queueKey);
383
+ entry.sessionId = sessionId;
384
+ activeSessions.set(sessionId, entry);
318
385
  entry.abort = () => {
319
- aborted = true;
320
- try { child.kill('SIGTERM'); } catch {}
321
- setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000);
386
+ try {
387
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
388
+ } catch { /* process may already be gone */ }
322
389
  };
390
+ if (!options.sessionId) send({ type: 'session-created', sessionId, agent: 'grok' });
391
+ client.listeners.set(sessionId, update => emitUpdate(update, send, state));
392
+ send({ type: 'status', message: 'Grok ACP running…', agent: 'grok', sessionId });
393
+ const result = await client.request('session/prompt', {
394
+ sessionId,
395
+ prompt: [{ type: 'text', text: command }],
396
+ });
397
+ if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
398
+ if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
399
+ entry.busy = false;
400
+ send({
401
+ type: 'result',
402
+ is_error: result.stopReason === 'error',
403
+ usage: result._meta?.usage || null,
404
+ sessionId,
405
+ agent: 'grok',
406
+ });
407
+ send({
408
+ type: 'complete',
409
+ exitCode: result.stopReason === 'cancelled' ? 130 : 0,
410
+ aborted: result.stopReason === 'cancelled',
411
+ sessionId,
412
+ agent: 'grok',
413
+ });
414
+ } catch (e) {
415
+ entry.busy = false;
416
+ if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
417
+ if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
418
+ send({ type: 'error', message: e.message, sessionId, agent: 'grok' });
419
+ send({ type: 'complete', exitCode: 1, sessionId, agent: 'grok' });
420
+ } finally {
421
+ if (sessionId) client.listeners.delete(sessionId);
422
+ activeSessions.delete(localKey);
423
+ if (sessionId) activeSessions.delete(sessionId);
424
+ }
425
+ }
323
426
 
324
- // Tell UI a run started (typing indicator already on; optional status)
325
- send({ type: 'status', message: 'Grok running…', agent: 'grok' });
427
+ /** Queue all Grok follow-ups at the edge. Promise resolves after this item runs. */
428
+ export function runGrok(command, options, send, { cliPath, activeSessions }) {
429
+ const key = (options.sessionId && queueAliases.get(options.sessionId))
430
+ || options.sessionId
431
+ || `new:${options.cwd || ''}`;
432
+ const queue = queues.get(key) || { items: [], running: false };
433
+ queues.set(key, queue);
434
+ return new Promise(resolve => {
435
+ queue.items.push({ command, options, send, resolve });
436
+ if (queue.running) {
437
+ send({
438
+ type: 'status',
439
+ message: `Grok follow-up queued (${queue.items.length - 1} ahead)`,
440
+ sessionId: options.sessionId,
441
+ agent: 'grok',
442
+ });
443
+ return;
444
+ }
445
+ queue.running = true;
446
+ void (async () => {
447
+ while (queue.items.length) {
448
+ const item = queue.items.shift();
449
+ await runOne(item, { cliPath, activeSessions, queueKey: key });
450
+ item.resolve();
451
+ }
452
+ queue.running = false;
453
+ queues.delete(key);
454
+ for (const [sid, alias] of queueAliases) if (alias === key) queueAliases.delete(sid);
455
+ })();
326
456
  });
327
457
  }
458
+
459
+ export async function interruptGrok(sessionId) {
460
+ for (const client of clients.values()) {
461
+ if (client.listeners.has(sessionId)) {
462
+ try {
463
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
469
+ }
470
+ return false;
471
+ }