claude-simple 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.
Files changed (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
package/server.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { createApp, init, PORT } from './src/app.js';
3
+
4
+ await init();
5
+
6
+ const { server } = createApp();
7
+
8
+ server.listen(PORT, () => {
9
+ console.log(`\n Claude Simple UI running at http://localhost:${PORT}\n`);
10
+ if (process.env.AUTH_USERNAME) console.log(` Auth: env user "${process.env.AUTH_USERNAME}"`);
11
+ console.log();
12
+ });
@@ -0,0 +1,58 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import crypto from 'crypto';
3
+
4
+ /**
5
+ * Run a Claude Code turn via the agent SDK.
6
+ * Emits the same WS-facing messages as the original runClaude:
7
+ * session-created, assistant, result, complete, error (+ raw SDK msgs).
8
+ */
9
+ export async function runClaude(command, options, send, { cliPath, activeSessions }) {
10
+ const sessionKey = options.sessionId || crypto.randomUUID();
11
+ const controller = new AbortController();
12
+ activeSessions.set(sessionKey, { controller, kind: 'claude', startTime: Date.now() });
13
+
14
+ const env = { ...process.env };
15
+ if (options.configDir) env.CLAUDE_CONFIG_DIR = options.configDir;
16
+
17
+ const sdkOptions = {
18
+ env,
19
+ pathToClaudeCodeExecutable: cliPath,
20
+ tools: { type: 'preset', preset: 'claude_code' },
21
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
22
+ settingSources: ['project', 'user', 'local'],
23
+ model: options.model || 'claude-sonnet-4-5',
24
+ abortSignal: controller.signal,
25
+ };
26
+ if (options.cwd) sdkOptions.cwd = options.cwd;
27
+ if (options.sessionId) sdkOptions.resume = options.sessionId;
28
+ if (options.effort) sdkOptions.effort = options.effort;
29
+ if (options.permissionMode && options.permissionMode !== 'default') {
30
+ sdkOptions.permissionMode = options.permissionMode;
31
+ }
32
+ if (options.allowDangerouslySkipPermissions) {
33
+ sdkOptions.allowDangerouslySkipPermissions = true;
34
+ }
35
+ if (options.allowedTools?.length) sdkOptions.allowedTools = options.allowedTools;
36
+
37
+ let capturedSessionId = null;
38
+
39
+ try {
40
+ const stream = query({ prompt: command, options: sdkOptions });
41
+ for await (const msg of stream) {
42
+ if (msg.type === 'system' && msg.session_id && !capturedSessionId) {
43
+ capturedSessionId = msg.session_id;
44
+ send({ type: 'session-created', sessionId: capturedSessionId, agent: 'claude' });
45
+ }
46
+ send(msg);
47
+ }
48
+ send({ type: 'complete', exitCode: 0, sessionId: capturedSessionId || sessionKey, agent: 'claude' });
49
+ } catch (err) {
50
+ if (err.name === 'AbortError') {
51
+ send({ type: 'complete', exitCode: 130, aborted: true, sessionId: capturedSessionId || sessionKey, agent: 'claude' });
52
+ } else {
53
+ send({ type: 'error', message: err.message, agent: 'claude' });
54
+ }
55
+ } finally {
56
+ activeSessions.delete(sessionKey);
57
+ }
58
+ }
@@ -0,0 +1,284 @@
1
+ import { spawn } from 'child_process';
2
+ import readline from 'readline';
3
+ import crypto from 'crypto';
4
+
5
+ /**
6
+ * Run OpenAI Codex CLI via `codex exec --json` (same approach as cc-connect).
7
+ * Multi-turn: first turn starts a thread; later turns use `exec resume <id>`.
8
+ *
9
+ * Emits normalized events the frontend already understands:
10
+ * session-created, assistant (text), tool_use-style via assistant tool blocks,
11
+ * result (usage), complete, error
12
+ */
13
+
14
+ function mapPermissionToMode(permissionMode) {
15
+ switch (permissionMode) {
16
+ case 'acceptEdits':
17
+ case 'auto':
18
+ return 'full-auto';
19
+ case 'bypassPermissions':
20
+ return 'yolo';
21
+ default:
22
+ return 'suggest';
23
+ }
24
+ }
25
+
26
+ function buildArgs(options, isResume) {
27
+ const mode = options.mode || mapPermissionToMode(options.permissionMode);
28
+ const args = isResume
29
+ ? ['exec', 'resume', '--skip-git-repo-check']
30
+ : ['exec', '--skip-git-repo-check'];
31
+
32
+ // exec has no approval IPC → approval_policy=never; sandbox enforces safety.
33
+ // resume does not accept --sandbox, so use -c overrides instead.
34
+ switch (mode) {
35
+ case 'auto-edit':
36
+ case 'full-auto':
37
+ if (isResume) {
38
+ args.push('-c', 'sandbox_mode="workspace-write"', '-c', 'approval_policy="never"');
39
+ } else {
40
+ args.push('--sandbox', 'workspace-write', '-c', 'approval_policy="never"');
41
+ }
42
+ break;
43
+ case 'yolo':
44
+ args.push('--dangerously-bypass-approvals-and-sandbox');
45
+ break;
46
+ default: // suggest
47
+ if (isResume) {
48
+ args.push('-c', 'sandbox_mode="read-only"', '-c', 'approval_policy="never"');
49
+ } else {
50
+ args.push('--sandbox', 'read-only', '-c', 'approval_policy="never"');
51
+ }
52
+ }
53
+
54
+ if (options.model) args.push('--model', options.model);
55
+ if (options.effort) args.push('-c', `model_reasoning_effort="${options.effort}"`);
56
+
57
+ if (isResume) {
58
+ args.push(options.sessionId, '--json', '-');
59
+ } else {
60
+ args.push('--json', '--cd', options.cwd || process.cwd(), '-');
61
+ }
62
+ return args;
63
+ }
64
+
65
+ function extractItemText(item) {
66
+ if (typeof item.text === 'string' && item.text) return item.text;
67
+ for (const key of ['content', 'output_text', 'summary', 'summary_text']) {
68
+ const v = item[key];
69
+ if (typeof v === 'string' && v) return v;
70
+ if (Array.isArray(v)) {
71
+ const parts = v
72
+ .map(p => (typeof p === 'string' ? p : p?.text || p?.output_text || ''))
73
+ .filter(Boolean);
74
+ if (parts.length) return parts.join('\n');
75
+ }
76
+ }
77
+ return '';
78
+ }
79
+
80
+ export function runCodex(command, options, send, { cliPath, activeSessions }) {
81
+ return new Promise((resolve) => {
82
+ const isResume = Boolean(options.sessionId);
83
+ const sessionKey = options.sessionId || crypto.randomUUID();
84
+ const args = buildArgs(options, isResume);
85
+ const bin = cliPath || 'codex';
86
+
87
+ const env = { ...process.env };
88
+ if (options.codexHome) env.CODEX_HOME = options.codexHome;
89
+
90
+ let child;
91
+ try {
92
+ child = spawn(bin, args, {
93
+ cwd: options.cwd || process.cwd(),
94
+ env,
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+ } catch (e) {
98
+ send({ type: 'error', message: `Failed to start codex: ${e.message}`, agent: 'codex' });
99
+ resolve();
100
+ return;
101
+ }
102
+
103
+ const entry = { kind: 'codex', child, startTime: Date.now() };
104
+ activeSessions.set(sessionKey, entry);
105
+ // Also register under thread id once known so abort-session works
106
+ let threadId = options.sessionId || null;
107
+ let pendingText = [];
108
+ let aborted = false;
109
+ let stderr = '';
110
+
111
+ child.stdin.write(command || '');
112
+ child.stdin.end();
113
+
114
+ child.stderr.on('data', d => { stderr += d.toString(); });
115
+
116
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
117
+
118
+ function flushText() {
119
+ // Dedupe identical bodies (CLI may emit agent_message + message for one turn)
120
+ const seen = new Set();
121
+ for (const text of pendingText) {
122
+ const t = text?.trim();
123
+ if (!t || seen.has(t)) continue;
124
+ seen.add(t);
125
+ // Stream-friendly: open bubble, dump text, close (codex emits completed items, not token deltas)
126
+ send({ type: 'assistant_stream_start', agent: 'codex', ts: Date.now() });
127
+ send({ type: 'assistant_delta', data: t, agent: 'codex' });
128
+ send({ type: 'assistant_stream_end', agent: 'codex' });
129
+ }
130
+ pendingText = [];
131
+ }
132
+
133
+ function handleItem(item, phase) {
134
+ if (!item || typeof item !== 'object') return;
135
+ const itemType = item.type;
136
+
137
+ if (itemType === 'agent_message' || itemType === 'message') {
138
+ if (phase === 'completed') {
139
+ const text = extractItemText(item)?.trim();
140
+ if (text && !pendingText.some(t => (t || '').trim() === text)) {
141
+ pendingText.push(text);
142
+ }
143
+ }
144
+ return;
145
+ }
146
+
147
+ if (itemType === 'reasoning') {
148
+ const text = extractItemText(item);
149
+ if (text && phase === 'completed') {
150
+ send({ type: 'assistant', message: { content: [{ type: 'text', text: `💭 ${text}` }] }, agent: 'codex' });
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (itemType === 'command_execution') {
156
+ const command = item.command || '';
157
+ if (phase === 'started') {
158
+ send({
159
+ type: 'assistant',
160
+ message: { content: [{ type: 'tool_use', name: 'Bash', input: { command } }] },
161
+ agent: 'codex',
162
+ });
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (itemType === 'function_call') {
168
+ const name = item.name || 'function';
169
+ let input = item.arguments;
170
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch { /* keep string */ }
171
+ if (phase === 'started' || phase === 'completed') {
172
+ send({
173
+ type: 'assistant',
174
+ message: { content: [{ type: 'tool_use', name, input: input ?? {} }] },
175
+ agent: 'codex',
176
+ });
177
+ }
178
+ return;
179
+ }
180
+
181
+ // Other known tools
182
+ const known = {
183
+ web_search: 'WebSearch',
184
+ file_search: 'FileSearch',
185
+ code_interpreter: 'CodeInterpreter',
186
+ mcp_tool: 'MCP',
187
+ };
188
+ if (known[itemType] && phase === 'completed') {
189
+ send({
190
+ type: 'assistant',
191
+ message: { content: [{ type: 'tool_use', name: known[itemType], input: item }] },
192
+ agent: 'codex',
193
+ });
194
+ }
195
+ }
196
+
197
+ rl.on('line', (line) => {
198
+ if (!line.trim()) return;
199
+ let raw;
200
+ try { raw = JSON.parse(line); } catch { return; }
201
+
202
+ const eventType = raw.type;
203
+ switch (eventType) {
204
+ case 'thread.started': {
205
+ if (raw.thread_id) {
206
+ threadId = raw.thread_id;
207
+ activeSessions.set(threadId, entry);
208
+ send({ type: 'session-created', sessionId: threadId, agent: 'codex' });
209
+ }
210
+ break;
211
+ }
212
+ case 'turn.started':
213
+ pendingText = [];
214
+ break;
215
+ case 'item.started':
216
+ handleItem(raw.item, 'started');
217
+ break;
218
+ case 'item.completed':
219
+ handleItem(raw.item, 'completed');
220
+ break;
221
+ case 'turn.completed': {
222
+ flushText();
223
+ const u = raw.usage || {};
224
+ send({
225
+ type: 'result',
226
+ is_error: false,
227
+ usage: {
228
+ inputTokens: u.input_tokens || 0,
229
+ outputTokens: u.output_tokens || 0,
230
+ cacheReadInputTokens: u.cached_input_tokens || 0,
231
+ },
232
+ sessionId: threadId || sessionKey,
233
+ agent: 'codex',
234
+ });
235
+ break;
236
+ }
237
+ case 'turn.failed': {
238
+ const msg = raw.error?.message || raw.message || 'turn failed';
239
+ send({ type: 'error', message: msg, agent: 'codex' });
240
+ break;
241
+ }
242
+ case 'error': {
243
+ const msg = raw.message || 'codex error';
244
+ if (!/Reconnecting|Falling back/i.test(msg)) {
245
+ send({ type: 'error', message: msg, agent: 'codex' });
246
+ }
247
+ break;
248
+ }
249
+ default:
250
+ break;
251
+ }
252
+ });
253
+
254
+ child.on('close', (code) => {
255
+ rl.close();
256
+ activeSessions.delete(sessionKey);
257
+ if (threadId) activeSessions.delete(threadId);
258
+ if (aborted) {
259
+ send({ type: 'complete', exitCode: 130, aborted: true, sessionId: threadId || sessionKey, agent: 'codex' });
260
+ } else if (code !== 0 && code != null) {
261
+ const errMsg = stderr.trim() || `codex exited with code ${code}`;
262
+ // Avoid double-error if we already sent turn.failed
263
+ send({ type: 'complete', exitCode: code, sessionId: threadId || sessionKey, agent: 'codex', error: errMsg });
264
+ if (stderr.trim()) send({ type: 'error', message: errMsg, agent: 'codex' });
265
+ } else {
266
+ send({ type: 'complete', exitCode: 0, sessionId: threadId || sessionKey, agent: 'codex' });
267
+ }
268
+ resolve();
269
+ });
270
+
271
+ child.on('error', (e) => {
272
+ activeSessions.delete(sessionKey);
273
+ if (threadId) activeSessions.delete(threadId);
274
+ send({ type: 'error', message: e.message, agent: 'codex' });
275
+ resolve();
276
+ });
277
+
278
+ entry.abort = () => {
279
+ aborted = true;
280
+ try { child.kill('SIGTERM'); } catch {}
281
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000);
282
+ };
283
+ });
284
+ }
@@ -0,0 +1,318 @@
1
+ import { spawn } from 'child_process';
2
+ import readline from 'readline';
3
+ import crypto from 'crypto';
4
+
5
+ /**
6
+ * Run Grok CLI headlessly with streaming-json over WebSocket (not HTTP SSE).
7
+ *
8
+ * New turn:
9
+ * grok -p <prompt> --output-format streaming-json --cwd <dir> ...
10
+ * Resume:
11
+ * grok --resume <sessionId> -p <prompt> --output-format streaming-json ...
12
+ *
13
+ * Stream events (line-delimited JSON):
14
+ * { type: "thought", data: "..." }
15
+ * { type: "text", data: "..." }
16
+ * { type: "tool_*", ... } // when CLI emits them
17
+ * { type: "end", sessionId, usage, ... }
18
+ *
19
+ * We forward deltas immediately so the UI updates live instead of dumping at end.
20
+ */
21
+
22
+ function mapPermission(permissionMode, allowBypass) {
23
+ if (allowBypass || permissionMode === 'bypassPermissions') return 'bypassPermissions';
24
+ if (permissionMode && permissionMode !== 'default') return permissionMode;
25
+ return null;
26
+ }
27
+
28
+ function buildArgs(command, options) {
29
+ const args = [];
30
+
31
+ if (options.sessionId) {
32
+ args.push('--resume', options.sessionId);
33
+ }
34
+
35
+ // -p = headless single-turn (required for non-TTY streaming-json)
36
+ args.push('-p', command || '');
37
+ args.push('--output-format', 'streaming-json');
38
+
39
+ if (options.cwd) args.push('--cwd', options.cwd);
40
+ if (options.model) args.push('--model', options.model);
41
+ if (options.effort) args.push('--reasoning-effort', options.effort);
42
+
43
+ const perm = mapPermission(options.permissionMode, options.allowDangerouslySkipPermissions);
44
+ if (perm) args.push('--permission-mode', perm);
45
+ if (options.allowDangerouslySkipPermissions || options.permissionMode === 'bypassPermissions') {
46
+ args.push('--always-approve');
47
+ }
48
+
49
+ if (options.newSessionId && !options.sessionId) {
50
+ args.push('--session-id', options.newSessionId);
51
+ }
52
+
53
+ return args;
54
+ }
55
+
56
+ function parseMaybeJson(v) {
57
+ if (v == null) return {};
58
+ if (typeof v === 'object') return v;
59
+ if (typeof v === 'string') {
60
+ try { return JSON.parse(v); } catch { return { input: v }; }
61
+ }
62
+ return { value: v };
63
+ }
64
+
65
+ export function runGrok(command, options, send, { cliPath, activeSessions }) {
66
+ return new Promise((resolve) => {
67
+ const sessionKey = options.sessionId || crypto.randomUUID();
68
+ const bin = cliPath || 'grok';
69
+ const args = buildArgs(command, options);
70
+
71
+ const env = { ...process.env };
72
+ env.CI = env.CI || '1';
73
+ // Reduce pipe buffering if the runtime honors it
74
+ env.PYTHONUNBUFFERED = '1';
75
+
76
+ let child;
77
+ try {
78
+ child = spawn(bin, args, {
79
+ cwd: options.cwd || process.cwd(),
80
+ env,
81
+ stdio: ['ignore', 'pipe', 'pipe'],
82
+ });
83
+ } catch (e) {
84
+ send({ type: 'error', message: `Failed to start grok: ${e.message}`, agent: 'grok' });
85
+ resolve();
86
+ return;
87
+ }
88
+
89
+ // Unbuffer stdout as much as possible
90
+ if (child.stdout?.setEncoding) child.stdout.setEncoding('utf8');
91
+
92
+ const entry = { kind: 'grok', child, startTime: Date.now() };
93
+ activeSessions.set(sessionKey, entry);
94
+
95
+ let sessionId = options.sessionId || null;
96
+ let aborted = false;
97
+ let stderr = '';
98
+ let gotEnd = false;
99
+ let streamOpen = false; // live assistant bubble on the client
100
+ let thoughtOpen = false;
101
+ let textChars = 0;
102
+ let thoughtChars = 0;
103
+
104
+ // Throttle thought deltas a bit (they're very chatty) but keep text live
105
+ let thoughtBuf = '';
106
+ let thoughtTimer = null;
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
+ if (streamOpen) {
122
+ send({ type: 'assistant_stream_end', agent: 'grok' });
123
+ streamOpen = false;
124
+ }
125
+ }
126
+
127
+ function flushThought(force = false) {
128
+ if (!thoughtBuf) return;
129
+ if (!force && thoughtBuf.length < 24) return;
130
+ if (!thoughtOpen) {
131
+ send({ type: 'thought_stream_start', agent: 'grok' });
132
+ thoughtOpen = true;
133
+ }
134
+ send({ type: 'thought_delta', data: thoughtBuf, agent: 'grok' });
135
+ thoughtChars += thoughtBuf.length;
136
+ thoughtBuf = '';
137
+ }
138
+
139
+ function scheduleThoughtFlush() {
140
+ if (thoughtTimer) return;
141
+ thoughtTimer = setTimeout(() => {
142
+ thoughtTimer = null;
143
+ flushThought(true);
144
+ }, 120);
145
+ }
146
+
147
+ function emitTool(raw) {
148
+ // Finalize any open text/thought before tool card
149
+ if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
150
+ flushThought(true);
151
+ if (thoughtOpen) {
152
+ send({ type: 'thought_stream_end', agent: 'grok' });
153
+ thoughtOpen = false;
154
+ }
155
+ endStream();
156
+
157
+ const name = raw.name || raw.tool || raw.toolName || raw.tool_name
158
+ || raw.title || raw.data?.name || 'tool';
159
+ let input = raw.input ?? raw.arguments ?? raw.args ?? raw.rawInput
160
+ ?? raw.data?.input ?? raw.data?.arguments ?? raw.data ?? {};
161
+ input = parseMaybeJson(input);
162
+
163
+ send({
164
+ type: 'assistant',
165
+ message: { content: [{ type: 'tool_use', name, input }] },
166
+ agent: 'grok',
167
+ ts: Date.now(),
168
+ });
169
+ }
170
+
171
+ rl.on('line', (line) => {
172
+ if (!line.trim()) return;
173
+ let raw;
174
+ try { raw = JSON.parse(line); } catch {
175
+ // Non-JSON progress line — surface lightly
176
+ send({ type: 'status', message: line.slice(0, 200), agent: 'grok' });
177
+ return;
178
+ }
179
+
180
+ const t = String(raw.type || '').toLowerCase();
181
+
182
+ // ── Text tokens (stream immediately) ─────────────────────────────────
183
+ if ((t === 'text' || t === 'message' || t === 'output_text') && raw.data != null) {
184
+ if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
185
+ flushThought(true);
186
+ if (thoughtOpen) {
187
+ send({ type: 'thought_stream_end', agent: 'grok' });
188
+ thoughtOpen = false;
189
+ }
190
+ const chunk = String(raw.data);
191
+ if (!chunk) return;
192
+ ensureStream();
193
+ textChars += chunk.length;
194
+ send({ type: 'assistant_delta', data: chunk, agent: 'grok' });
195
+ return;
196
+ }
197
+
198
+ // ── Thinking tokens (throttled stream) ───────────────────────────────
199
+ if ((t === 'thought' || t === 'thinking' || t === 'reasoning') && raw.data != null) {
200
+ thoughtBuf += String(raw.data);
201
+ scheduleThoughtFlush();
202
+ return;
203
+ }
204
+
205
+ // ── Tools ───────────────────────────────────────────────────────────
206
+ if (
207
+ t === 'tool_use' || t === 'tool_call' || t === 'tool_start'
208
+ || t === 'tool' || t === 'function_call' || t === 'tool_call_start'
209
+ || t.startsWith('tool_')
210
+ ) {
211
+ // Avoid treating tool_result as a new call
212
+ if (t === 'tool_result' || t === 'tool_call_update' || t === 'tool_end' || t === 'tool_call_end') {
213
+ return;
214
+ }
215
+ emitTool(raw);
216
+ return;
217
+ }
218
+
219
+ // ── Session id early (some builds emit before end) ───────────────────
220
+ if ((t === 'session' || t === 'session_started') && (raw.sessionId || raw.session_id)) {
221
+ sessionId = raw.sessionId || raw.session_id;
222
+ activeSessions.set(sessionId, entry);
223
+ if (!options.sessionId) {
224
+ send({ type: 'session-created', sessionId, agent: 'grok' });
225
+ }
226
+ return;
227
+ }
228
+
229
+ // ── End of turn ─────────────────────────────────────────────────────
230
+ if (t === 'end' || t === 'result' || t === 'done') {
231
+ gotEnd = true;
232
+ if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
233
+ flushThought(true);
234
+ if (thoughtOpen) {
235
+ send({ type: 'thought_stream_end', agent: 'grok' });
236
+ thoughtOpen = false;
237
+ }
238
+ endStream();
239
+
240
+ if (raw.sessionId || raw.session_id) {
241
+ sessionId = raw.sessionId || raw.session_id;
242
+ activeSessions.set(sessionId, entry);
243
+ if (!options.sessionId) {
244
+ send({ type: 'session-created', sessionId, agent: 'grok' });
245
+ }
246
+ }
247
+
248
+ const u = raw.usage || {};
249
+ const costTicks = raw.total_cost_usd_ticks ?? u.cost_usd_ticks ?? u.costUsdTicks;
250
+ const costUSD = raw.total_cost_usd ?? u.cost_usd ?? u.costUSD
251
+ ?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
252
+
253
+ send({
254
+ type: 'result',
255
+ is_error: false,
256
+ usage: {
257
+ inputTokens: u.input_tokens || u.inputTokens || 0,
258
+ outputTokens: u.output_tokens || u.outputTokens || 0,
259
+ cacheReadInputTokens: u.cache_read_input_tokens || u.cachedReadTokens || 0,
260
+ totalTokens: u.total_tokens || u.totalTokens
261
+ || ((u.input_tokens || u.inputTokens || 0) + (u.output_tokens || u.outputTokens || 0)),
262
+ reasoningTokens: u.reasoning_tokens || u.reasoningTokens || 0,
263
+ costUSD,
264
+ },
265
+ sessionId: sessionId || sessionKey,
266
+ agent: 'grok',
267
+ stats: { textChars, thoughtChars },
268
+ });
269
+ return;
270
+ }
271
+
272
+ if (t === 'error') {
273
+ send({ type: 'error', message: raw.message || raw.data || 'grok error', agent: 'grok' });
274
+ }
275
+ });
276
+
277
+ child.on('close', (code) => {
278
+ if (thoughtTimer) { clearTimeout(thoughtTimer); thoughtTimer = null; }
279
+ flushThought(true);
280
+ if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
281
+ endStream();
282
+ rl.close();
283
+ activeSessions.delete(sessionKey);
284
+ if (sessionId) activeSessions.delete(sessionId);
285
+
286
+ if (aborted) {
287
+ send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'grok' });
288
+ } else if (code !== 0 && code != null && !gotEnd) {
289
+ const errMsg = stderr.trim().split('\n').filter(l => !/stdin is not a terminal/i.test(l)).join('\n')
290
+ || `grok exited with code ${code}`;
291
+ send({ type: 'error', message: errMsg, agent: 'grok' });
292
+ send({ type: 'complete', exitCode: code, sessionId: sessionId || sessionKey, agent: 'grok' });
293
+ } else {
294
+ if (sessionId && !options.sessionId && !gotEnd) {
295
+ send({ type: 'session-created', sessionId, agent: 'grok' });
296
+ }
297
+ send({ type: 'complete', exitCode: code ?? 0, sessionId: sessionId || sessionKey, agent: 'grok' });
298
+ }
299
+ resolve();
300
+ });
301
+
302
+ child.on('error', (e) => {
303
+ activeSessions.delete(sessionKey);
304
+ if (sessionId) activeSessions.delete(sessionId);
305
+ send({ type: 'error', message: e.message, agent: 'grok' });
306
+ resolve();
307
+ });
308
+
309
+ entry.abort = () => {
310
+ aborted = true;
311
+ try { child.kill('SIGTERM'); } catch {}
312
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000);
313
+ };
314
+
315
+ // Tell UI a run started (typing indicator already on; optional status)
316
+ send({ type: 'status', message: 'Grok running…', agent: 'grok' });
317
+ });
318
+ }