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.
- package/README.md +225 -0
- package/client.js +139 -0
- package/dist/addon-fit.min.js +2 -0
- package/dist/app.0.1.0.min.js +825 -0
- package/dist/highlight.min.css +10 -0
- package/dist/highlight.min.js +1244 -0
- package/dist/index.html +16 -0
- package/dist/marked.min.js +6 -0
- package/dist/style.0.1.0.min.css +1050 -0
- package/dist/xterm.min.css +209 -0
- package/dist/xterm.min.js +2 -0
- package/gateway.js +349 -0
- package/package.json +41 -0
- package/public/addon-fit.min.js +2 -0
- package/public/agent-panel.js +1079 -0
- package/public/api.js +78 -0
- package/public/app.js +95 -0
- package/public/base.css +4 -0
- package/public/cache.js +27 -0
- package/public/chat.js +1087 -0
- package/public/files.js +476 -0
- package/public/git.js +130 -0
- package/public/index.html +61 -0
- package/public/lib.js +2 -0
- package/public/router.js +71 -0
- package/public/settings.js +90 -0
- package/public/shell/boot.js +159 -0
- package/public/shell/dashboard.js +269 -0
- package/public/shell/hub.js +192 -0
- package/public/shell/layout.js +195 -0
- package/public/shell/notes.js +323 -0
- package/public/shell/session-context.js +19 -0
- package/public/shell/session-list.js +442 -0
- package/public/shell/session-nav.js +299 -0
- package/public/shell/templates.js +342 -0
- package/public/shell/util.js +63 -0
- package/public/shell.js +589 -0
- package/public/state.js +362 -0
- package/public/style.css +1048 -0
- package/public/terminal.js +177 -0
- package/public/xterm.min.css +209 -0
- package/public/xterm.min.js +2 -0
- package/server.js +12 -0
- package/src/agents/claude.js +58 -0
- package/src/agents/codex.js +284 -0
- package/src/agents/grok.js +318 -0
- package/src/agents/meta-agent.js +1952 -0
- package/src/agents/models.js +339 -0
- package/src/agents/sessions.js +1508 -0
- 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
|
+
}
|