claude-simple 0.2.6 → 0.2.8

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 (60) hide show
  1. package/README.md +2 -0
  2. package/dist/app.0.2.8.min.js +1080 -0
  3. package/dist/index.html +2 -2
  4. package/dist/style.0.2.8.min.css +1598 -0
  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 +245 -1
  9. package/public/api.js +4 -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 +118 -17
  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 +2 -2
  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 +72 -23
  31. package/public/shell/hub.js +0 -0
  32. package/public/shell/layout.js +41 -12
  33. package/public/shell/notes.js +0 -0
  34. package/public/shell/session-context.js +0 -0
  35. package/public/shell/session-list.js +27 -28
  36. package/public/shell/session-nav.js +36 -1
  37. package/public/shell/templates.js +45 -7
  38. package/public/shell/util.js +0 -0
  39. package/public/shell.js +56 -7
  40. package/public/state.js +53 -13
  41. package/public/style.css +100 -6
  42. package/public/sw.js +2 -2
  43. package/public/terminal.js +77 -3
  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 +1 -1
  48. package/src/agents/codex.js +1 -0
  49. package/src/agents/grok.js +180 -298
  50. package/src/agents/meta-agent.js +332 -1
  51. package/src/agents/models.js +0 -0
  52. package/src/agents/sessions.js +106 -26
  53. package/src/agents/stream-batch.js +0 -0
  54. package/src/app.js +88 -11
  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.6.min.js +0 -953
  60. package/dist/style.0.2.6.min.css +0 -1504
@@ -11,6 +11,37 @@ let lastMachine = null;
11
11
  let shellGen = 0;
12
12
  let shellReconnectTimer = null;
13
13
  let shellWanted = false;
14
+ let terminalEventsBound = false;
15
+ const mobileKeyData = {
16
+ 'ctrl-c': '\x03', escape: '\x1b', tab: '\t',
17
+ up: '\x1b[A', down: '\x1b[B', left: '\x1b[D', right: '\x1b[C'
18
+ };
19
+
20
+ function sendInput(data) {
21
+ if (ws && ws.readyState === WebSocket.OPEN) {
22
+ ws.send(JSON.stringify({ type: 'input', data }));
23
+ }
24
+ }
25
+
26
+ function isMobileShell() {
27
+ return window.matchMedia('(max-width: 640px), (hover: none) and (pointer: coarse)').matches;
28
+ }
29
+
30
+ /** Mobile IMEs do not reliably drive xterm's off-screen textarea. Use a real
31
+ * input there so composition, prediction and paste remain visible and intact. */
32
+ function focusTerminal() {
33
+ if (isMobileShell()) {
34
+ document.getElementById('term-mobile-input')?.focus({ preventScroll: true });
35
+ return;
36
+ }
37
+ term?.focus();
38
+ const input = document.querySelector('#terminal-container .xterm-helper-textarea');
39
+ if (input) {
40
+ input.focus({ preventScroll: true });
41
+ // iOS may ignore the first focus while its visual viewport is settling.
42
+ requestAnimationFrame(() => input.focus({ preventScroll: true }));
43
+ }
44
+ }
14
45
 
15
46
  function initXterm() {
16
47
  if (term) return;
@@ -59,15 +90,35 @@ function initXterm() {
59
90
  fitTerminal();
60
91
 
61
92
  term.onData(data => {
62
- if (ws && ws.readyState === WebSocket.OPEN) {
63
- ws.send(JSON.stringify({ type: 'input', data }));
64
- }
93
+ sendInput(data);
65
94
  });
66
95
 
96
+ // On phones this focuses the native command input; desktop keeps xterm's
97
+ // direct keyboard interaction.
98
+ container.addEventListener('pointerdown', () => focusTerminal(), { passive: true });
99
+ container.addEventListener('touchend', () => focusTerminal(), { passive: true });
100
+
67
101
  window.addEventListener('resize', () => {
68
102
  fitTerminal();
69
103
  sendResize();
70
104
  });
105
+
106
+ const vv = window.visualViewport;
107
+ if (vv) {
108
+ let raf = 0;
109
+ const fitForVisualViewport = () => {
110
+ if (raf) cancelAnimationFrame(raf);
111
+ raf = requestAnimationFrame(() => {
112
+ raf = 0;
113
+ if (currentTab.peek() === 'shell') {
114
+ fitTerminal();
115
+ sendResize();
116
+ }
117
+ });
118
+ };
119
+ vv.addEventListener('resize', fitForVisualViewport);
120
+ vv.addEventListener('scroll', fitForVisualViewport);
121
+ }
71
122
  }
72
123
 
73
124
  function fitTerminal() {
@@ -177,6 +228,29 @@ function activateShellTab() {
177
228
  }
178
229
 
179
230
  export function initTerminal() {
231
+ if (!terminalEventsBound) {
232
+ terminalEventsBound = true;
233
+ document.getElementById('term-keyboard')?.addEventListener('click', focusTerminal);
234
+ document.getElementById('term-mobile-composer')?.addEventListener('submit', event => {
235
+ event.preventDefault();
236
+ const input = document.getElementById('term-mobile-input');
237
+ if (!input) return;
238
+ sendInput(`${input.value}\r`);
239
+ input.value = '';
240
+ input.focus({ preventScroll: true });
241
+ });
242
+ document.getElementById('term-mobile-keys')?.addEventListener('pointerdown', event => {
243
+ // Keep the native command input focused while using the touch shortcut row.
244
+ if (event.target.closest('[data-term-key]')) event.preventDefault();
245
+ });
246
+ document.getElementById('term-mobile-keys')?.addEventListener('click', event => {
247
+ const key = event.target.closest('[data-term-key]')?.dataset.termKey;
248
+ if (!key || !mobileKeyData[key]) return;
249
+ sendInput(mobileKeyData[key]);
250
+ focusTerminal();
251
+ });
252
+ }
253
+
180
254
  document.addEventListener('visibilitychange', () => {
181
255
  if (document.visibilityState !== 'visible') return;
182
256
  if (currentTab.peek() !== 'shell' || !shellWanted) return;
File without changes
File without changes
File without changes
@@ -23,7 +23,7 @@ export async function runClaude(command, options, send, { cliPath, activeSession
23
23
  const env = { ...process.env };
24
24
  if (options.configDir) env.CLAUDE_CONFIG_DIR = options.configDir;
25
25
 
26
- const permissionMode = options.permissionMode || 'default';
26
+ const permissionMode = options.permissionMode || 'bypassPermissions';
27
27
  const autoApprove = permissionMode === 'bypassPermissions'
28
28
  || permissionMode === 'acceptEdits'
29
29
  || permissionMode === 'auto'
@@ -12,6 +12,7 @@ import crypto from 'crypto';
12
12
  */
13
13
 
14
14
  function mapPermissionToMode(permissionMode) {
15
+ if (permissionMode == null) return 'yolo';
15
16
  switch (permissionMode) {
16
17
  case 'acceptEdits':
17
18
  case 'auto':
@@ -1,326 +1,208 @@
1
1
  import { spawn } from 'child_process';
2
2
  import readline from 'readline';
3
3
  import crypto from 'crypto';
4
- import { createDeltaBatcher } from './stream-batch.js';
5
4
 
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
- */
5
+ // Grok's ACP transport is a long-lived JSON-RPC process. Keeping it alive is
6
+ // important: it owns the real Grok session and lets the edge, rather than an
7
+ // individual browser tab, serialize follow-up prompts.
8
+ const clients = new Map();
9
+ const queues = new Map();
10
+ const queueAliases = new Map();
22
11
 
23
- function mapPermission(permissionMode, allowBypass) {
24
- if (allowBypass || permissionMode === 'bypassPermissions') return 'bypassPermissions';
25
- if (permissionMode && permissionMode !== 'default') return permissionMode;
26
- return null;
12
+ function profileKey(options) {
13
+ return `${options.model || ''}\u001f${options.effort || ''}`;
27
14
  }
28
15
 
29
- function buildArgs(command, options) {
30
- const args = [];
31
-
32
- if (options.sessionId) {
33
- args.push('--resume', options.sessionId);
34
- }
35
-
36
- // -p = headless single-turn (required for non-TTY streaming-json)
37
- args.push('-p', command || '');
38
- args.push('--output-format', 'streaming-json');
39
-
40
- if (options.cwd) args.push('--cwd', options.cwd);
41
- if (options.model) args.push('--model', options.model);
42
- if (options.effort) args.push('--reasoning-effort', options.effort);
43
-
44
- const perm = mapPermission(options.permissionMode, options.allowDangerouslySkipPermissions);
45
- if (perm) args.push('--permission-mode', perm);
46
- if (options.allowDangerouslySkipPermissions || options.permissionMode === 'bypassPermissions') {
47
- args.push('--always-approve');
48
- }
49
-
50
- if (options.newSessionId && !options.sessionId) {
51
- args.push('--session-id', options.newSessionId);
16
+ class GrokACPClient {
17
+ constructor({ cliPath, model, effort }) {
18
+ const args = ['agent', '--always-approve'];
19
+ if (model) args.push('--model', model);
20
+ if (effort) args.push('--reasoning-effort', effort);
21
+ args.push('stdio');
22
+ this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
23
+ this.pending = new Map();
24
+ this.listeners = new Map();
25
+ this.nextID = 1;
26
+ this.ready = this.initialize();
27
+ this.child.stderr.on('data', d => console.warn(`[grok acp] ${String(d).trim()}`));
28
+ this.child.on('close', () => {
29
+ for (const { reject } of this.pending.values()) reject(new Error('Grok ACP process stopped'));
30
+ this.pending.clear();
31
+ for (const fn of this.listeners.values()) fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
32
+ this.listeners.clear();
33
+ });
34
+ const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
35
+ rl.on('line', line => this.receive(line));
52
36
  }
53
37
 
54
- return args;
55
- }
56
-
57
- function parseMaybeJson(v) {
58
- if (v == null) return {};
59
- if (typeof v === 'object') return v;
60
- if (typeof v === 'string') {
61
- try { return JSON.parse(v); } catch { return { input: v }; }
38
+ async initialize() {
39
+ const init = await this.request('initialize', {
40
+ protocolVersion: 1,
41
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
42
+ clientInfo: { name: 'claude-simple-ui', version: '0.2.7' },
43
+ });
44
+ const methods = new Set((init.authMethods || []).map(x => x?.id));
45
+ const methodId = process.env.XAI_API_KEY && methods.has('xai.api_key')
46
+ ? 'xai.api_key'
47
+ : (methods.has('cached_token') ? 'cached_token' : null);
48
+ if (methodId) await this.request('authenticate', { methodId, _meta: { headless: true } });
62
49
  }
63
- return { value: v };
64
- }
65
-
66
- export function runGrok(command, options, send, { cliPath, activeSessions }) {
67
- return new Promise((resolve) => {
68
- const sessionKey = options.sessionId || crypto.randomUUID();
69
- const bin = cliPath || 'grok';
70
- const args = buildArgs(command, options);
71
-
72
- const env = { ...process.env };
73
- env.CI = env.CI || '1';
74
- // Reduce pipe buffering if the runtime honors it
75
- env.PYTHONUNBUFFERED = '1';
76
50
 
77
- let child;
78
- try {
79
- child = spawn(bin, args, {
80
- cwd: options.cwd || process.cwd(),
81
- env,
82
- stdio: ['ignore', 'pipe', 'pipe'],
83
- });
84
- } catch (e) {
85
- send({ type: 'error', message: `Failed to start grok: ${e.message}`, agent: 'grok' });
86
- resolve();
51
+ receive(line) {
52
+ let msg;
53
+ try { msg = JSON.parse(line); } catch { return; }
54
+ if (msg.id != null && this.pending.has(msg.id)) {
55
+ const pending = this.pending.get(msg.id);
56
+ this.pending.delete(msg.id);
57
+ if (msg.error) pending.reject(new Error(msg.error.message || 'Grok ACP request failed'));
58
+ else pending.resolve(msg.result || {});
87
59
  return;
88
60
  }
89
-
90
- // Unbuffer stdout as much as possible
91
- if (child.stdout?.setEncoding) child.stdout.setEncoding('utf8');
92
-
93
- const entry = { kind: 'grok', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
94
- activeSessions.set(sessionKey, entry);
95
-
96
- let sessionId = options.sessionId || null;
97
- let aborted = false;
98
- let stderr = '';
99
- let gotEnd = false;
100
- let streamOpen = false; // live assistant bubble on the client
101
- let thoughtOpen = false;
102
- let textChars = 0;
103
- let thoughtChars = 0;
104
-
105
- const textBatch = createDeltaBatcher(send, 'assistant_delta', { agent: 'grok' }, { maxWait: 48, maxSize: 480 });
106
- const thoughtBatch = createDeltaBatcher(send, 'thought_delta', { agent: 'grok' }, { maxWait: 140, maxSize: 600 });
107
-
108
- child.stderr.on('data', d => { stderr += d.toString(); });
109
-
110
- // Use 'readable' + line split for lower latency than waiting for large chunks
111
- const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
112
-
113
- function ensureStream() {
114
- if (!streamOpen) {
115
- send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() });
116
- streamOpen = true;
117
- }
118
- }
119
-
120
- function endStream() {
121
- textBatch.flush();
122
- if (streamOpen) {
123
- send({ type: 'assistant_stream_end', agent: 'grok' });
124
- streamOpen = false;
125
- }
126
- }
127
-
128
- function flushThought() {
129
- if (!thoughtBatch.pending()) return;
130
- if (!thoughtOpen) {
131
- send({ type: 'thought_stream_start', agent: 'grok' });
132
- thoughtOpen = true;
133
- }
134
- const n = thoughtBatch.pending();
135
- thoughtBatch.flush();
136
- thoughtChars += n;
61
+ // Grok's documented ACP client sample does not need client-side tool RPC.
62
+ // Return a structured error for optional extension calls instead of leaving
63
+ // an agent request hanging forever.
64
+ if (msg.id != null && msg.method) {
65
+ this.write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Unsupported client method: ${msg.method}` } });
66
+ return;
137
67
  }
138
-
139
- function emitTool(raw) {
140
- // Finalize any open text/thought before tool card
141
- flushThought();
142
- if (thoughtOpen) {
143
- send({ type: 'thought_stream_end', agent: 'grok' });
144
- thoughtOpen = false;
145
- }
146
- endStream();
147
-
148
- const name = raw.name || raw.tool || raw.toolName || raw.tool_name
149
- || raw.title || raw.data?.name || 'tool';
150
- let input = raw.input ?? raw.arguments ?? raw.args ?? raw.rawInput
151
- ?? raw.data?.input ?? raw.data?.arguments ?? raw.data ?? {};
152
- input = parseMaybeJson(input);
153
-
154
- send({
155
- type: 'assistant',
156
- message: { content: [{ type: 'tool_use', name, input }] },
157
- agent: 'grok',
158
- ts: Date.now(),
159
- });
68
+ if (msg.method === 'session/update') {
69
+ const sid = msg.params?.sessionId;
70
+ const fn = sid && this.listeners.get(sid);
71
+ if (fn) fn(msg.params?.update || {});
160
72
  }
73
+ }
161
74
 
162
- rl.on('line', (line) => {
163
- if (!line.trim()) return;
164
- let raw;
165
- try { raw = JSON.parse(line); } catch {
166
- // Non-JSON progress line — surface lightly
167
- send({ type: 'status', message: line.slice(0, 200), agent: 'grok' });
168
- return;
169
- }
170
-
171
- const t = String(raw.type || '').toLowerCase();
172
-
173
- // ── Text tokens (batched ~50ms) ──────────────────────────────────────
174
- if ((t === 'text' || t === 'message' || t === 'output_text') && raw.data != null) {
175
- flushThought();
176
- if (thoughtOpen) {
177
- send({ type: 'thought_stream_end', agent: 'grok' });
178
- thoughtOpen = false;
179
- }
180
- const chunk = String(raw.data);
181
- if (!chunk) return;
182
- ensureStream();
183
- textChars += chunk.length;
184
- textBatch.push(chunk);
185
- return;
186
- }
187
-
188
- // ── Thinking tokens (batched stream) ────────────────────────────────
189
- if ((t === 'thought' || t === 'thinking' || t === 'reasoning') && raw.data != null) {
190
- if (!thoughtOpen) {
191
- send({ type: 'thought_stream_start', agent: 'grok' });
192
- thoughtOpen = true;
193
- }
194
- thoughtBatch.push(String(raw.data));
195
- return;
196
- }
197
-
198
- // ── Tools ───────────────────────────────────────────────────────────
199
- if (
200
- t === 'tool_use' || t === 'tool_call' || t === 'tool_start'
201
- || t === 'tool' || t === 'function_call' || t === 'tool_call_start'
202
- || t.startsWith('tool_')
203
- ) {
204
- // Avoid treating tool_result as a new call
205
- if (t === 'tool_result' || t === 'tool_call_update' || t === 'tool_end' || t === 'tool_call_end') {
206
- return;
207
- }
208
- emitTool(raw);
209
- return;
210
- }
211
-
212
- // ── Session id early (some builds emit before end) ───────────────────
213
- if ((t === 'session' || t === 'session_started') && (raw.sessionId || raw.session_id)) {
214
- sessionId = raw.sessionId || raw.session_id;
215
- entry.sessionId = sessionId;
216
- activeSessions.set(sessionId, entry);
217
- if (!options.sessionId) {
218
- send({ type: 'session-created', sessionId, agent: 'grok' });
219
- }
220
- return;
221
- }
222
-
223
- // ── End of turn ─────────────────────────────────────────────────────
224
- if (t === 'end' || t === 'result' || t === 'done') {
225
- gotEnd = true;
226
- flushThought();
227
- if (thoughtOpen) {
228
- send({ type: 'thought_stream_end', agent: 'grok' });
229
- thoughtOpen = false;
230
- }
231
- endStream();
232
-
233
- if (raw.sessionId || raw.session_id) {
234
- sessionId = raw.sessionId || raw.session_id;
235
- entry.sessionId = sessionId;
236
- activeSessions.set(sessionId, entry);
237
- if (!options.sessionId) {
238
- send({ type: 'session-created', sessionId, agent: 'grok' });
239
- }
240
- }
241
-
242
- // Unlock UI immediately — process may linger after the turn ends.
243
- entry.busy = false;
244
-
245
- const u = raw.usage || {};
246
- const costTicks = raw.total_cost_usd_ticks ?? u.cost_usd_ticks ?? u.costUsdTicks;
247
- const costUSD = raw.total_cost_usd ?? u.cost_usd ?? u.costUSD
248
- ?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
75
+ write(data) {
76
+ if (!this.child.stdin?.writable) throw new Error('Grok ACP stdin is unavailable');
77
+ this.child.stdin.write(JSON.stringify(data) + '\n');
78
+ }
249
79
 
250
- const sid = sessionId || sessionKey;
251
- send({
252
- type: 'result',
253
- is_error: false,
254
- usage: {
255
- inputTokens: u.input_tokens || u.inputTokens || 0,
256
- outputTokens: u.output_tokens || u.outputTokens || 0,
257
- cacheReadInputTokens: u.cache_read_input_tokens || u.cachedReadTokens || 0,
258
- totalTokens: u.total_tokens || u.totalTokens
259
- || ((u.input_tokens || u.inputTokens || 0) + (u.output_tokens || u.outputTokens || 0)),
260
- reasoningTokens: u.reasoning_tokens || u.reasoningTokens || 0,
261
- costUSD,
262
- },
263
- sessionId: sid,
264
- agent: 'grok',
265
- stats: { textChars, thoughtChars },
266
- });
267
- // Early complete so client can send next turn without waiting for process exit.
268
- send({ type: 'complete', exitCode: 0, sessionId: sid, agent: 'grok' });
269
- return;
270
- }
80
+ request(method, params) {
81
+ const id = this.nextID++;
82
+ return new Promise((resolve, reject) => {
83
+ this.pending.set(id, { resolve, reject });
84
+ try { this.write({ jsonrpc: '2.0', id, method, params }); }
85
+ catch (e) { this.pending.delete(id); reject(e); }
86
+ });
87
+ }
271
88
 
272
- if (t === 'error') {
273
- entry.busy = false;
274
- send({ type: 'error', message: raw.message || raw.data || 'grok error', agent: 'grok' });
89
+ async ensureSession(options) {
90
+ await this.ready;
91
+ if (options.sessionId) {
92
+ try {
93
+ await this.request('session/load', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
94
+ } catch {
95
+ await this.request('session/resume', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
275
96
  }
97
+ return options.sessionId;
98
+ }
99
+ const result = await this.request('session/new', {
100
+ cwd: options.cwd || process.cwd(),
101
+ mcpServers: [],
102
+ _meta: { yoloMode: true },
276
103
  });
104
+ if (!result.sessionId) throw new Error('Grok ACP did not return a session id');
105
+ return result.sessionId;
106
+ }
107
+ }
277
108
 
278
- child.on('close', (code) => {
279
- flushThought();
280
- if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
281
- endStream();
282
- rl.close();
283
- entry.busy = false;
284
- activeSessions.delete(sessionKey);
285
- if (sessionId) activeSessions.delete(sessionId);
286
-
287
- // Skip duplicate complete if turn already finished via streaming-json "end".
288
- if (gotEnd && !aborted) {
289
- resolve();
290
- return;
291
- }
109
+ function clientFor(options, cliPath) {
110
+ const key = profileKey(options);
111
+ let client = clients.get(key);
112
+ if (!client || client.child.exitCode != null) {
113
+ client = new GrokACPClient({ cliPath, model: options.model, effort: options.effort });
114
+ clients.set(key, client);
115
+ }
116
+ return client;
117
+ }
292
118
 
293
- if (aborted) {
294
- send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'grok' });
295
- } else if (code !== 0 && code != null && !gotEnd) {
296
- const errMsg = stderr.trim().split('\n').filter(l => !/stdin is not a terminal/i.test(l)).join('\n')
297
- || `grok exited with code ${code}`;
298
- send({ type: 'error', message: errMsg, agent: 'grok' });
299
- send({ type: 'complete', exitCode: code, sessionId: sessionId || sessionKey, agent: 'grok' });
300
- } else {
301
- if (sessionId && !options.sessionId && !gotEnd) {
302
- send({ type: 'session-created', sessionId, agent: 'grok' });
303
- }
304
- send({ type: 'complete', exitCode: code ?? 0, sessionId: sessionId || sessionKey, agent: 'grok' });
305
- }
306
- resolve();
307
- });
119
+ function emitUpdate(update, send, sessionId, state) {
120
+ const kind = update?.sessionUpdate;
121
+ if (kind === 'agent_message_chunk' && update.content?.text) {
122
+ if (!state.textOpen) { send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() }); state.textOpen = true; }
123
+ send({ type: 'assistant_delta', data: update.content.text, agent: 'grok' });
124
+ } else if (kind === 'agent_thought_chunk' && update.content?.text) {
125
+ if (!state.thoughtOpen) { send({ type: 'thought_stream_start', agent: 'grok' }); state.thoughtOpen = true; }
126
+ send({ type: 'thought_delta', data: update.content.text, agent: 'grok' });
127
+ } else if (kind === 'tool_call' || kind === 'tool_call_update') {
128
+ if (state.textOpen) { send({ type: 'assistant_stream_end', agent: 'grok' }); state.textOpen = false; }
129
+ if (state.thoughtOpen) { send({ type: 'thought_stream_end', agent: 'grok' }); state.thoughtOpen = false; }
130
+ if (kind === 'tool_call') {
131
+ send({ type: 'assistant', agent: 'grok', message: { content: [{
132
+ type: 'tool_use', name: update.title || update.kind || 'tool', input: update.rawInput || update.input || {},
133
+ }] } });
134
+ }
135
+ }
136
+ }
308
137
 
309
- child.on('error', (e) => {
310
- entry.busy = false;
311
- activeSessions.delete(sessionKey);
312
- if (sessionId) activeSessions.delete(sessionId);
313
- send({ type: 'error', message: e.message, agent: 'grok' });
314
- resolve();
138
+ async function runOne(item, { cliPath, activeSessions, queueKey }) {
139
+ const { command, options, send } = item;
140
+ const client = clientFor(options, cliPath);
141
+ const localKey = options.sessionId || crypto.randomUUID();
142
+ const entry = { kind: 'grok-acp', startTime: Date.now(), busy: true, key: localKey, sessionId: options.sessionId || null };
143
+ activeSessions.set(localKey, entry);
144
+ const state = { textOpen: false, thoughtOpen: false };
145
+ let sessionId = options.sessionId;
146
+ try {
147
+ sessionId = await client.ensureSession(options);
148
+ queueAliases.set(sessionId, queueKey);
149
+ entry.sessionId = sessionId;
150
+ activeSessions.set(sessionId, entry);
151
+ entry.abort = () => { client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } }); };
152
+ if (!options.sessionId) send({ type: 'session-created', sessionId, agent: 'grok' });
153
+ client.listeners.set(sessionId, update => emitUpdate(update, send, sessionId, state));
154
+ const result = await client.request('session/prompt', {
155
+ sessionId,
156
+ prompt: [{ type: 'text', text: command }],
315
157
  });
158
+ if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
159
+ if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
160
+ entry.busy = false;
161
+ send({ type: 'result', is_error: result.stopReason === 'error', usage: result._meta?.usage || null, sessionId, agent: 'grok' });
162
+ send({ type: 'complete', exitCode: result.stopReason === 'cancelled' ? 130 : 0, aborted: result.stopReason === 'cancelled', sessionId, agent: 'grok' });
163
+ } catch (e) {
164
+ entry.busy = false;
165
+ send({ type: 'error', message: e.message, sessionId, agent: 'grok' });
166
+ send({ type: 'complete', exitCode: 1, sessionId, agent: 'grok' });
167
+ } finally {
168
+ if (sessionId) client.listeners.delete(sessionId);
169
+ activeSessions.delete(localKey);
170
+ if (sessionId) activeSessions.delete(sessionId);
171
+ }
172
+ }
316
173
 
317
- entry.abort = () => {
318
- aborted = true;
319
- try { child.kill('SIGTERM'); } catch {}
320
- setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000);
174
+ /** Queue all Grok follow-ups at the edge. The promise resolves after this item runs. */
175
+ export function runGrok(command, options, send, { cliPath, activeSessions }) {
176
+ const key = (options.sessionId && queueAliases.get(options.sessionId)) || options.sessionId || `new:${options.cwd || ''}`;
177
+ const queue = queues.get(key) || { items: [], running: false };
178
+ queues.set(key, queue);
179
+ return new Promise(resolve => {
180
+ queue.items.push({ command, options, send, resolve });
181
+ if (queue.running) {
182
+ send({ type: 'status', message: `Grok follow-up queued (${queue.items.length - 1} ahead)`, sessionId: options.sessionId, agent: 'grok' });
183
+ return;
184
+ }
185
+ queue.running = true;
186
+ const drain = async () => {
187
+ while (queue.items.length) {
188
+ const item = queue.items.shift();
189
+ await runOne(item, { cliPath, activeSessions, queueKey: key });
190
+ item.resolve();
191
+ }
192
+ queue.running = false;
193
+ queues.delete(key);
194
+ for (const [sid, alias] of queueAliases) if (alias === key) queueAliases.delete(sid);
321
195
  };
322
-
323
- // Tell UI a run started (typing indicator already on; optional status)
324
- send({ type: 'status', message: 'Grok running…', agent: 'grok' });
196
+ void drain();
325
197
  });
326
198
  }
199
+
200
+ export async function interruptGrok(sessionId) {
201
+ for (const client of clients.values()) {
202
+ if (client.listeners.has(sessionId)) {
203
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
204
+ return true;
205
+ }
206
+ }
207
+ return false;
208
+ }