openzoo 0.49.9 → 0.49.10
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 +18 -4
- package/bin/openzoo.js +2 -3
- package/lib/claudecode.js +107 -723
- package/lib/grokui.mjs +125 -548
- package/lib/launch.js +29 -159
- package/lib/models.js +57 -19
- package/lib/proxy.js +79 -5
- package/lib/racesettle.js +23 -2
- package/lib/responses-stream.js +176 -0
- package/package.json +2 -2
- package/lib/relay.js +0 -275
package/lib/claudecode.js
CHANGED
|
@@ -1,72 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Drive Claude Code
|
|
3
|
-
*
|
|
2
|
+
* Drive Claude Code (`claude --print --output-format stream-json`) as grokui
|
|
3
|
+
* Auto. This is the same harness `openzoo claude` launches: env from
|
|
4
|
+
* claudeZooEnv (ANTHROPIC_BASE_URL + AUTH_TOKEN at :8402, no Anthropic
|
|
5
|
+
* API key). Tools are Claude Code's own (Bash, Read, Write, Edit, Glob, Grep).
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* this environment" because those screens are the Ink TUI. Chat-box lines
|
|
8
|
-
* (including Claude slashes) are written to PTY stdin; folded output is
|
|
9
|
-
* painted on the grokui canvas.
|
|
10
|
-
*
|
|
11
|
-
* Env is the same writer as `openzoo claude`: claudeZooEnv
|
|
12
|
-
* (ANTHROPIC_BASE_URL=http://localhost:8402/v1, ANTHROPIC_AUTH_TOKEN=sk-openzoo,
|
|
13
|
-
* ANTHROPIC_API_KEY unset). cwd is the thread dir. bypassPermissions stays.
|
|
14
|
-
*
|
|
15
|
-
* Attach order: node-pty when it is already installed (optional; Electron
|
|
16
|
-
* would need its own ABI rebuild), else Mac/Linux `script` which allocates a
|
|
17
|
-
* host PTY. Windows without node-pty is not first-class — say so rather than
|
|
18
|
-
* falling back to --print.
|
|
7
|
+
* Do not re-parse RUN:/WRITE:/DONE: here. Stream-json is Claude Code's
|
|
8
|
+
* official print protocol; we only fold those events onto the canvas.
|
|
19
9
|
*/
|
|
20
10
|
import { spawn } from 'node:child_process';
|
|
21
|
-
import {
|
|
22
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
-
import os from 'node:os';
|
|
24
|
-
import path from 'node:path';
|
|
25
|
-
import { claudeZooEnv, resolveOpenzooClaude } from './launch.js';
|
|
26
|
-
import { isAutoModel } from './modelroute.js';
|
|
11
|
+
import { claudeZooEnv, resolveClaudeCli } from './launch.js';
|
|
27
12
|
|
|
28
13
|
export const AUTO_CLAUDE_SYSTEM = 'You are grokui Auto, paid per call through the local OpenZoo proxy (x402 on :8402). '
|
|
29
14
|
+ 'Use your native tools (Bash, Read, Write, Edit, Glob, Grep) to do the work in this working directory. '
|
|
30
15
|
+ 'Do not curl localhost:8402/v1/chat/completions and do not emit RUN:/WRITE:/DONE: text directives — you already have real tools. '
|
|
31
16
|
+ 'Do not ask the user to type continue.';
|
|
32
17
|
|
|
33
|
-
export const CLAUDE_MISSING = '
|
|
34
|
-
+ '
|
|
18
|
+
export const CLAUDE_MISSING = 'claude CLI not found. Auto is the Claude Code harness via OpenZoo — install it with: '
|
|
19
|
+
+ 'curl -fsSL https://claude.ai/install.sh | bash (then ensure ~/.local/bin is on PATH). '
|
|
35
20
|
+ 'No Anthropic login: `openzoo claude` already points ANTHROPIC_BASE_URL at the local proxy.';
|
|
36
21
|
|
|
37
|
-
export const PTY_WINDOWS = 'Auto PTY is Mac/Linux first (`script` host PTY). On Windows install node-pty '
|
|
38
|
-
+ '(conpty) — --print cannot grow the TUI, so we do not fall back to it.';
|
|
39
|
-
|
|
40
|
-
const SESSION_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
41
|
-
const TERM_ROWS = 36;
|
|
42
|
-
const TERM_COLS = 120;
|
|
43
|
-
|
|
44
22
|
let runnerOverride = null;
|
|
45
23
|
export function setClaudeRunnerForTest(fn) {
|
|
46
24
|
runnerOverride = typeof fn === 'function' ? fn : null;
|
|
47
25
|
}
|
|
48
26
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* refreshes the canvas. Leave --model off and let the OpenZoo env pick. */
|
|
57
|
-
export function claudeModelArg(model) {
|
|
58
|
-
const id = String(model || '').trim();
|
|
59
|
-
if (!id || isAutoModel(id)) return undefined;
|
|
60
|
-
return id;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function claudeInteractiveArgs({ sessionId, model, system } = {}) {
|
|
64
|
-
const args = ['--permission-mode', 'bypassPermissions'];
|
|
27
|
+
export function claudePrintArgs({ prompt, sessionId, model, system } = {}) {
|
|
28
|
+
const args = [
|
|
29
|
+
'--print',
|
|
30
|
+
'--verbose',
|
|
31
|
+
'--output-format', 'stream-json',
|
|
32
|
+
'--permission-mode', 'bypassPermissions',
|
|
33
|
+
];
|
|
65
34
|
const sys = system === undefined ? AUTO_CLAUDE_SYSTEM : system;
|
|
66
35
|
if (sys) args.push('--append-system-prompt', sys);
|
|
67
36
|
if (sessionId) args.push('--resume', String(sessionId));
|
|
68
|
-
|
|
69
|
-
if (
|
|
37
|
+
if (model) args.push('--model', String(model));
|
|
38
|
+
if (prompt != null && prompt !== '') args.push(String(prompt));
|
|
70
39
|
return args;
|
|
71
40
|
}
|
|
72
41
|
|
|
@@ -94,98 +63,9 @@ export function paymentFailText(text) {
|
|
|
94
63
|
return '';
|
|
95
64
|
}
|
|
96
65
|
|
|
97
|
-
const SYSTEM_REMINDER = /<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi;
|
|
98
|
-
const SYSTEM_REMINDER_OPEN = /<system-reminder\b[^>]*>[\s\S]*$/i;
|
|
99
|
-
const CURRENT_DIR_LINE = /^[ \t]*(?:#\s*)?currentDir\b[^\n]*\n?/gim;
|
|
100
|
-
const HARNESS_DUMP_LINE = /^(?:RUN|WRITE|SPAWN|READ|EDIT|GLOB|GREP|MULTIEDIT):\s*.*$/gim;
|
|
101
|
-
|
|
102
|
-
/** Gzip magic, NULs, or UTF-8 replacement diamonds — never paint that. */
|
|
103
|
-
export function looksBinaryCanvas(raw) {
|
|
104
|
-
if (raw == null) return false;
|
|
105
|
-
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw)) {
|
|
106
|
-
if (raw.length >= 2 && raw[0] === 0x1f && raw[1] === 0x8b) return true;
|
|
107
|
-
raw = raw.toString('utf8');
|
|
108
|
-
}
|
|
109
|
-
const s = String(raw);
|
|
110
|
-
if (!s) return false;
|
|
111
|
-
if (s.includes('\uFFFD')) return true;
|
|
112
|
-
if (s.charCodeAt(0) === 0x1f && s.charCodeAt(1) === 0x8b) return true;
|
|
113
|
-
let ctrl = 0;
|
|
114
|
-
const n = Math.min(s.length, 256);
|
|
115
|
-
for (let i = 0; i < n; i++) {
|
|
116
|
-
const c = s.charCodeAt(i);
|
|
117
|
-
if (c === 0 || c < 8 || (c > 13 && c < 32)) ctrl += 1;
|
|
118
|
-
}
|
|
119
|
-
return ctrl >= 4;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function apiErrorCode(text) {
|
|
123
|
-
const s = String(text || '');
|
|
124
|
-
const api = /API Error:\s*(\d{3})\b/i.exec(s);
|
|
125
|
-
if (api) return api[1];
|
|
126
|
-
const http = /\bHTTP\s+(\d{3})\b/i.exec(s);
|
|
127
|
-
if (http && Number(http[1]) >= 400) return http[1];
|
|
128
|
-
return '';
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Short readable canvas line for a dead child. Binary / API Error 400
|
|
133
|
-
* bodies become `upstream HTTP 400` — never the gzip dump.
|
|
134
|
-
*/
|
|
135
|
-
export function canvasHttpErrorLine(text, { error = false } = {}) {
|
|
136
|
-
const s = String(text || '');
|
|
137
|
-
const code = apiErrorCode(s);
|
|
138
|
-
if (looksBinaryCanvas(s)) return `upstream HTTP ${code || '400'}`;
|
|
139
|
-
if (code && code !== '402' && (error || /^\s*API Error:/im.test(s))) {
|
|
140
|
-
return `upstream HTTP ${code}`;
|
|
141
|
-
}
|
|
142
|
-
return '';
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function stripClaudeNoise(text) {
|
|
146
|
-
let s = String(text || '');
|
|
147
|
-
s = s.replace(SYSTEM_REMINDER, '');
|
|
148
|
-
s = s.replace(SYSTEM_REMINDER_OPEN, '');
|
|
149
|
-
s = s.replace(CURRENT_DIR_LINE, '');
|
|
150
|
-
s = s.replace(HARNESS_DUMP_LINE, '');
|
|
151
|
-
return s.replace(/^\n+|\n+$/g, '').trim();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function looksLikeToolJsonDump(text) {
|
|
155
|
-
const t = String(text || '').trim();
|
|
156
|
-
if (!t.startsWith('{') || !t.endsWith('}')) return false;
|
|
157
|
-
try {
|
|
158
|
-
const j = JSON.parse(t);
|
|
159
|
-
if (!j || typeof j !== 'object') return false;
|
|
160
|
-
return Boolean(
|
|
161
|
-
j.tool_use || j.tool_result || j.type === 'tool_use' || j.type === 'tool_result'
|
|
162
|
-
|| j.file_path || j.currentDir || j['system-reminder'],
|
|
163
|
-
);
|
|
164
|
-
} catch {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* What grokui may paint as the Auto bubble. Model prose stays; RUN dumps,
|
|
171
|
-
* tool JSON, system-reminder / currentDir blocks, and binary 400s do not.
|
|
172
|
-
*/
|
|
173
|
-
export function sanitizeClaudeCanvas(text, { error = false } = {}) {
|
|
174
|
-
if (text == null) return '';
|
|
175
|
-
const raw = typeof Buffer !== 'undefined' && Buffer.isBuffer(text)
|
|
176
|
-
? text.toString('utf8')
|
|
177
|
-
: String(text);
|
|
178
|
-
const err = canvasHttpErrorLine(raw, { error });
|
|
179
|
-
if (err) return err;
|
|
180
|
-
const s = stripClaudeNoise(raw);
|
|
181
|
-
if (!s) return '';
|
|
182
|
-
if (looksLikeToolJsonDump(s)) return '';
|
|
183
|
-
return s;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
66
|
/**
|
|
187
67
|
* Fold one Claude Code stream-json object into a grokui-sized event.
|
|
188
|
-
*
|
|
68
|
+
* Not a RUN: parser — just the official print protocol.
|
|
189
69
|
*/
|
|
190
70
|
export function foldClaudeEvent(ev) {
|
|
191
71
|
if (!ev || typeof ev !== 'object') return null;
|
|
@@ -194,28 +74,12 @@ export function foldClaudeEvent(ev) {
|
|
|
194
74
|
return { kind: 'init', sessionId, model: ev.model, tools: ev.tools || [] };
|
|
195
75
|
}
|
|
196
76
|
if (ev.type === 'stream_event') {
|
|
197
|
-
const
|
|
198
|
-
const delta = event.delta;
|
|
199
|
-
const block = event.content_block || event.contentBlock;
|
|
77
|
+
const delta = ev.event?.delta;
|
|
200
78
|
if (delta?.type === 'thinking_delta' && delta.thinking) {
|
|
201
79
|
return { kind: 'think', text: delta.thinking, sessionId };
|
|
202
80
|
}
|
|
203
81
|
if (delta?.type === 'text_delta' && delta.text) {
|
|
204
|
-
return { kind: 'text', text:
|
|
205
|
-
}
|
|
206
|
-
if (event.type === 'content_block_start' && block?.type === 'tool_use') {
|
|
207
|
-
return { kind: 'tool', name: block.name, input: block.input || {}, sessionId };
|
|
208
|
-
}
|
|
209
|
-
if (event.type === 'content_block_start' && block?.type === 'thinking') {
|
|
210
|
-
return { kind: 'think', text: '', sessionId };
|
|
211
|
-
}
|
|
212
|
-
return { kind: 'partial', sessionId };
|
|
213
|
-
}
|
|
214
|
-
if (ev.type === 'user' && ev.message) {
|
|
215
|
-
const content = ev.message.content;
|
|
216
|
-
const blocks = Array.isArray(content) ? content : [];
|
|
217
|
-
if (blocks.some((b) => b && b.type === 'tool_result')) {
|
|
218
|
-
return { kind: 'tool_result', sessionId };
|
|
82
|
+
return { kind: 'text', text: delta.text, sessionId };
|
|
219
83
|
}
|
|
220
84
|
return { kind: 'partial', sessionId };
|
|
221
85
|
}
|
|
@@ -227,13 +91,13 @@ export function foldClaudeEvent(ev) {
|
|
|
227
91
|
for (const b of blocks) {
|
|
228
92
|
if (!b || typeof b !== 'object') continue;
|
|
229
93
|
if (b.type === 'thinking' && b.thinking) thinking.push(b.thinking);
|
|
230
|
-
else if (b.type === 'text' && b.text) text.push(
|
|
94
|
+
else if (b.type === 'text' && b.text) text.push(b.text);
|
|
231
95
|
else if (b.type === 'tool_use') tools.push({ name: b.name, input: b.input || {} });
|
|
232
96
|
}
|
|
233
97
|
return {
|
|
234
98
|
kind: 'assistant',
|
|
235
99
|
thinking: thinking.join('\n'),
|
|
236
|
-
text: text.
|
|
100
|
+
text: text.join(''),
|
|
237
101
|
tools,
|
|
238
102
|
sessionId,
|
|
239
103
|
};
|
|
@@ -241,11 +105,10 @@ export function foldClaudeEvent(ev) {
|
|
|
241
105
|
if (ev.type === 'result') {
|
|
242
106
|
const raw = ev.result != null ? String(ev.result) : (ev.error != null ? String(ev.error) : '');
|
|
243
107
|
const pay = paymentFailText(raw);
|
|
244
|
-
const error = Boolean(ev.is_error || ev.subtype === 'error' || ev.subtype === 'error_during_execution');
|
|
245
108
|
return {
|
|
246
109
|
kind: 'result',
|
|
247
|
-
text:
|
|
248
|
-
error,
|
|
110
|
+
text: raw,
|
|
111
|
+
error: Boolean(ev.is_error || ev.subtype === 'error' || ev.subtype === 'error_during_execution'),
|
|
249
112
|
paymentFailed: pay,
|
|
250
113
|
sessionId,
|
|
251
114
|
};
|
|
@@ -259,482 +122,6 @@ function parseNdjsonLine(line) {
|
|
|
259
122
|
try { return JSON.parse(s); } catch { return null; }
|
|
260
123
|
}
|
|
261
124
|
|
|
262
|
-
export function looksRawToolJson(s) {
|
|
263
|
-
const t = String(s || '').trim();
|
|
264
|
-
if (!t.startsWith('{') && !t.startsWith('[')) return false;
|
|
265
|
-
const raw = parseNdjsonLine(t);
|
|
266
|
-
if (!raw || typeof raw !== 'object') return false;
|
|
267
|
-
if (raw.type === 'tool_use' || raw.type === 'assistant' || raw.type === 'stream_event'
|
|
268
|
-
|| raw.type === 'system' || raw.type === 'result' || raw.type === 'user') return true;
|
|
269
|
-
if (Array.isArray(raw.content) && raw.content.some((c) => c && c.type === 'tool_use')) return true;
|
|
270
|
-
if (raw.tool_use_id || raw.file_path && raw.content && raw.type) return true;
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export function stripAnsi(s) {
|
|
275
|
-
return String(s ?? '')
|
|
276
|
-
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
277
|
-
.replace(/\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07)/g, '')
|
|
278
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
279
|
-
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
280
|
-
.replace(/\x1b./g, '');
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function nonTextRatio(buf) {
|
|
284
|
-
if (!buf.length) return 0;
|
|
285
|
-
let n = 0;
|
|
286
|
-
for (let i = 0; i < buf.length; i++) {
|
|
287
|
-
const c = buf[i];
|
|
288
|
-
if (c === 9 || c === 10 || c === 13) continue;
|
|
289
|
-
if (c < 32 || c === 0x7f) n += 1;
|
|
290
|
-
}
|
|
291
|
-
return n / buf.length;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Binary / diamond-mojibake HTTP 400 bodies become a short line.
|
|
296
|
-
* Do not paint `` from a gzip 400.
|
|
297
|
-
*/
|
|
298
|
-
export function sanitizeClaudeOutput(input) {
|
|
299
|
-
const buf = Buffer.isBuffer(input) ? input : Buffer.from(String(input ?? ''), 'utf8');
|
|
300
|
-
if (!buf.length) return '';
|
|
301
|
-
const utf = buf.toString('utf8');
|
|
302
|
-
const latin = buf.toString('latin1');
|
|
303
|
-
const diamonds = utf.split('\uFFFD').length - 1;
|
|
304
|
-
const binary = buf.includes(0) || diamonds >= 3 || nonTextRatio(buf) > 0.18
|
|
305
|
-
|| (buf[0] === 0x1f && buf[1] === 0x8b);
|
|
306
|
-
const mentions400 = /\bHTTP\/?\s*1\.[01]\s*400\b|\bstatus["']?\s*[:=]\s*400\b|\b400 Bad Request\b|\bupstream[^.\n]{0,40}400\b/i.test(latin)
|
|
307
|
-
|| /\bHTTP 400\b|\bstatus["']?\s*[:=]\s*400\b|\b400 Bad Request\b/i.test(utf);
|
|
308
|
-
if (binary && (mentions400 || diamonds >= 3 || (buf[0] === 0x1f && buf[1] === 0x8b))) {
|
|
309
|
-
return 'upstream HTTP 400';
|
|
310
|
-
}
|
|
311
|
-
if (mentions400 && diamonds) return 'upstream HTTP 400';
|
|
312
|
-
return utf;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function tuiLooksIdle(plain) {
|
|
316
|
-
const s = String(plain || '').replace(/[ \t]+$/g, '');
|
|
317
|
-
const lines = s.split('\n').filter((l) => l.trim());
|
|
318
|
-
const last = (lines[lines.length - 1] || '').trim();
|
|
319
|
-
if (/^(?:>|❯|➜|›)\s*$/.test(last)) return true;
|
|
320
|
-
if (/^(?:>|❯)\s+\S/.test(last) && /type |message|prompt/i.test(s.slice(-400))) return true;
|
|
321
|
-
if (/^\?\s/.test(last) && /agents|tasks|select/i.test(s.slice(-800))) return true;
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const TOOL_LINE = /^(?:[●◆✶✻▸➤]|[-*])\s+(Read|Write|Edit|Bash|Glob|Grep|Task|NotebookEdit)\s+(\S.*)?$/i;
|
|
326
|
-
const THINK_LINE = /^(?:[✻✶*·]\s*)?(?:thinking|thoughts?)\b[:.…\s]*/i;
|
|
327
|
-
const SPINNER_ONLY = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷\s]+$/;
|
|
328
|
-
const FILE_TRAIL = /^(?:\s*(?:⎿|└|├|│)\s+|\s{2,}(?:Found|Loaded|Wrote|Edited|Read)\b)/;
|
|
329
|
-
|
|
330
|
-
export function foldTuiText(raw) {
|
|
331
|
-
const sanitized = sanitizeClaudeOutput(raw);
|
|
332
|
-
if (sanitized === 'upstream HTTP 400') {
|
|
333
|
-
return { text: 'upstream HTTP 400', thinking: '', tools: [], paymentFailed: '' };
|
|
334
|
-
}
|
|
335
|
-
const plain = stripAnsi(sanitized)
|
|
336
|
-
.replace(/\r\n/g, '\n')
|
|
337
|
-
.replace(/\r/g, '\n')
|
|
338
|
-
.replace(/[╭╮╰╯│─┌┐└┘├┤┬┴┼━┃┏┓┗┛║═╔╗╚╝]/g, '')
|
|
339
|
-
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷]/g, '');
|
|
340
|
-
const thinking = [];
|
|
341
|
-
const text = [];
|
|
342
|
-
const tools = [];
|
|
343
|
-
let inThink = false;
|
|
344
|
-
for (const line of plain.split('\n')) {
|
|
345
|
-
const trimmed = line.trim();
|
|
346
|
-
if (!trimmed || SPINNER_ONLY.test(trimmed)) continue;
|
|
347
|
-
if (looksRawToolJson(trimmed)) continue;
|
|
348
|
-
if (FILE_TRAIL.test(line) && !TOOL_LINE.test(trimmed)) continue;
|
|
349
|
-
const tool = TOOL_LINE.exec(trimmed);
|
|
350
|
-
if (tool) {
|
|
351
|
-
const name = tool[1][0].toUpperCase() + tool[1].slice(1);
|
|
352
|
-
const rest = (tool[2] || '').trim();
|
|
353
|
-
tools.push(name === 'Bash' ? { name, input: { command: rest } } : { name, input: { file_path: rest } });
|
|
354
|
-
inThink = false;
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
if (THINK_LINE.test(trimmed)) {
|
|
358
|
-
const rest = trimmed.replace(THINK_LINE, '').trim();
|
|
359
|
-
if (rest) thinking.push(rest);
|
|
360
|
-
else thinking.push('thinking…');
|
|
361
|
-
inThink = true;
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
if (tuiLooksIdle(trimmed) && /^(?:>|❯|➜|›)\s*$/.test(trimmed)) continue;
|
|
365
|
-
if (inThink && trimmed.length < 200 && !/^(?:here's|here is|i |the |done|wrote|created)\b/i.test(trimmed)) {
|
|
366
|
-
thinking.push(trimmed);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
inThink = false;
|
|
370
|
-
text.push(line.replace(/[ \t]+$/g, ''));
|
|
371
|
-
}
|
|
372
|
-
const visible = text.join('\n').replace(/^\n+|\n+$/g, '').replace(/\n{3,}/g, '\n\n');
|
|
373
|
-
const pay = paymentFailText(plain);
|
|
374
|
-
const canvasErr = canvasHttpErrorLine(plain, { error: true });
|
|
375
|
-
return {
|
|
376
|
-
text: pay || canvasErr || sanitizeClaudeCanvas(visible),
|
|
377
|
-
thinking: thinking.join('\n').replace(/^\n+|\n+$/g, ''),
|
|
378
|
-
tools,
|
|
379
|
-
paymentFailed: pay,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** Minimal VT so TUI redraws become a screen, not a dump of CSI junk. */
|
|
384
|
-
export class TinyTerm {
|
|
385
|
-
constructor(rows = TERM_ROWS, cols = TERM_COLS) {
|
|
386
|
-
this.rows = rows;
|
|
387
|
-
this.cols = cols;
|
|
388
|
-
this.scrollback = [];
|
|
389
|
-
this.r = 0;
|
|
390
|
-
this.c = 0;
|
|
391
|
-
this._blank();
|
|
392
|
-
}
|
|
393
|
-
_blank() {
|
|
394
|
-
this.grid = Array.from({ length: this.rows }, () => Array(this.cols).fill(' '));
|
|
395
|
-
}
|
|
396
|
-
_put(ch) {
|
|
397
|
-
if (this.c >= this.cols) this._nl();
|
|
398
|
-
if (this.r >= this.rows) this.r = this.rows - 1;
|
|
399
|
-
this.grid[this.r][this.c] = ch;
|
|
400
|
-
this.c += 1;
|
|
401
|
-
}
|
|
402
|
-
_nl() {
|
|
403
|
-
this.c = 0;
|
|
404
|
-
this.r += 1;
|
|
405
|
-
if (this.r >= this.rows) {
|
|
406
|
-
const top = this.grid.shift().join('').replace(/ +$/g, '');
|
|
407
|
-
if (top.trim()) this.scrollback.push(top);
|
|
408
|
-
if (this.scrollback.length > 400) this.scrollback.splice(0, this.scrollback.length - 400);
|
|
409
|
-
this.grid.push(Array(this.cols).fill(' '));
|
|
410
|
-
this.r = this.rows - 1;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
write(chunk) {
|
|
414
|
-
const s = String(chunk ?? '');
|
|
415
|
-
let i = 0;
|
|
416
|
-
while (i < s.length) {
|
|
417
|
-
const ch = s[i];
|
|
418
|
-
if (ch === '\x1b') {
|
|
419
|
-
const rest = s.slice(i);
|
|
420
|
-
const osc = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/);
|
|
421
|
-
if (osc) { i += osc[0].length; continue; }
|
|
422
|
-
const csi = rest.match(/^\x1b\[([0-9;?]*)([ -/]*[@-~])/);
|
|
423
|
-
if (csi) {
|
|
424
|
-
this._csi(csi[1], csi[2]);
|
|
425
|
-
i += csi[0].length;
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
i += rest[1] ? 2 : 1;
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (ch === '\n') { this._nl(); i += 1; continue; }
|
|
432
|
-
if (ch === '\r') { this.c = 0; i += 1; continue; }
|
|
433
|
-
if (ch === '\b') { this.c = Math.max(0, this.c - 1); i += 1; continue; }
|
|
434
|
-
if (ch === '\t') { this.c = Math.min(this.cols - 1, this.c + (8 - (this.c % 8))); i += 1; continue; }
|
|
435
|
-
if (ch < ' ' && ch !== '') { i += 1; continue; }
|
|
436
|
-
this._put(ch);
|
|
437
|
-
i += 1;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
_csi(params, cmd) {
|
|
441
|
-
const parts = String(params || '').split(';').map((n) => (n === '' ? 0 : Number(n)));
|
|
442
|
-
const n = (i, d) => { const v = parts[i]; return Number.isFinite(v) && v > 0 ? v : d; };
|
|
443
|
-
switch (cmd) {
|
|
444
|
-
case 'A': this.r = Math.max(0, this.r - n(0, 1)); break;
|
|
445
|
-
case 'B': this.r = Math.min(this.rows - 1, this.r + n(0, 1)); break;
|
|
446
|
-
case 'C': this.c = Math.min(this.cols - 1, this.c + n(0, 1)); break;
|
|
447
|
-
case 'D': this.c = Math.max(0, this.c - n(0, 1)); break;
|
|
448
|
-
case 'H':
|
|
449
|
-
case 'f': {
|
|
450
|
-
const row = Math.max(1, parts[0] || 1) - 1;
|
|
451
|
-
const col = Math.max(1, parts[1] || 1) - 1;
|
|
452
|
-
this.r = Math.min(this.rows - 1, row);
|
|
453
|
-
this.c = Math.min(this.cols - 1, col);
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
456
|
-
case 'J': {
|
|
457
|
-
const mode = parts[0] || 0;
|
|
458
|
-
if (mode === 2 || mode === 3) this._blank();
|
|
459
|
-
break;
|
|
460
|
-
}
|
|
461
|
-
case 'K': {
|
|
462
|
-
const mode = parts[0] || 0;
|
|
463
|
-
if (mode === 2) this.grid[this.r] = Array(this.cols).fill(' ');
|
|
464
|
-
else if (mode === 1) {
|
|
465
|
-
for (let x = 0; x <= this.c; x++) this.grid[this.r][x] = ' ';
|
|
466
|
-
} else {
|
|
467
|
-
for (let x = this.c; x < this.cols; x++) this.grid[this.r][x] = ' ';
|
|
468
|
-
}
|
|
469
|
-
break;
|
|
470
|
-
}
|
|
471
|
-
default:
|
|
472
|
-
break;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
text() {
|
|
476
|
-
const screen = this.grid.map((row) => row.join('').replace(/ +$/g, ''));
|
|
477
|
-
return [...this.scrollback, ...screen].join('\n').replace(/^\n+|\n+$/g, '');
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function latestClaudeSessionId(cwd, home = os.homedir()) {
|
|
482
|
-
const root = path.join(home, '.claude', 'projects');
|
|
483
|
-
if (!existsSync(root) || !cwd) return '';
|
|
484
|
-
const slug = String(path.resolve(cwd)).replace(/[^A-Za-z0-9]/g, '-');
|
|
485
|
-
let dir = path.join(root, slug);
|
|
486
|
-
if (!existsSync(dir)) {
|
|
487
|
-
let hit = '';
|
|
488
|
-
try {
|
|
489
|
-
for (const name of readdirSync(root)) {
|
|
490
|
-
if (name === slug || name.endsWith(slug) || slug.endsWith(name)) {
|
|
491
|
-
hit = path.join(root, name);
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
} catch { /* none */ }
|
|
496
|
-
if (!hit) return '';
|
|
497
|
-
dir = hit;
|
|
498
|
-
}
|
|
499
|
-
let best = '';
|
|
500
|
-
let bestM = 0;
|
|
501
|
-
try {
|
|
502
|
-
for (const name of readdirSync(dir)) {
|
|
503
|
-
const m = name.match(new RegExp(`^(${SESSION_UUID.source})\\.jsonl$`, 'i'));
|
|
504
|
-
if (!m) continue;
|
|
505
|
-
let t = 0;
|
|
506
|
-
try { t = statSync(path.join(dir, name)).mtimeMs; } catch { t = 0; }
|
|
507
|
-
if (t >= bestM) { bestM = t; best = m[1]; }
|
|
508
|
-
}
|
|
509
|
-
} catch { /* none */ }
|
|
510
|
-
return best;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function shEscape(s) {
|
|
514
|
-
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function loadNodePty() {
|
|
518
|
-
try {
|
|
519
|
-
const require = createRequire(import.meta.url);
|
|
520
|
-
return require('node-pty');
|
|
521
|
-
} catch {
|
|
522
|
-
return null;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function ptyEnv(env) {
|
|
527
|
-
return {
|
|
528
|
-
...env,
|
|
529
|
-
TERM: env.TERM || 'xterm-256color',
|
|
530
|
-
COLORTERM: env.COLORTERM || 'truecolor',
|
|
531
|
-
COLUMNS: String(env.COLUMNS || TERM_COLS),
|
|
532
|
-
LINES: String(env.LINES || TERM_ROWS),
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Spawn `cli args` on a PTY. Returns a handle: write, onData, onExit, kill, kind.
|
|
538
|
-
*/
|
|
539
|
-
export function spawnClaudePty({ cli, args, cwd, env }) {
|
|
540
|
-
const runEnv = ptyEnv(env || process.env);
|
|
541
|
-
const nodePty = loadNodePty();
|
|
542
|
-
if (nodePty?.spawn) {
|
|
543
|
-
const term = nodePty.spawn(cli, args, {
|
|
544
|
-
name: 'xterm-256color',
|
|
545
|
-
cols: TERM_COLS,
|
|
546
|
-
rows: TERM_ROWS,
|
|
547
|
-
cwd: cwd || process.cwd(),
|
|
548
|
-
env: runEnv,
|
|
549
|
-
});
|
|
550
|
-
return {
|
|
551
|
-
kind: 'node-pty',
|
|
552
|
-
write: (s) => { try { term.write(s); } catch { /* closed */ } },
|
|
553
|
-
onData: (fn) => { term.onData((d) => fn(Buffer.from(String(d), 'utf8'))); },
|
|
554
|
-
onExit: (fn) => { term.onExit(({ exitCode }) => fn(exitCode ?? 0)); },
|
|
555
|
-
kill: () => { try { term.kill(); } catch { /* gone */ } },
|
|
556
|
-
pid: term.pid,
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
if (process.platform === 'win32') {
|
|
560
|
-
const err = new Error(PTY_WINDOWS);
|
|
561
|
-
err.code = 'PTY_WINDOWS';
|
|
562
|
-
throw err;
|
|
563
|
-
}
|
|
564
|
-
const scriptBin = existsSync('/usr/bin/script') ? '/usr/bin/script' : 'script';
|
|
565
|
-
let child;
|
|
566
|
-
if (process.platform === 'darwin') {
|
|
567
|
-
child = spawn(scriptBin, ['-q', '/dev/null', cli, ...args], {
|
|
568
|
-
cwd: cwd || process.cwd(),
|
|
569
|
-
env: runEnv,
|
|
570
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
571
|
-
});
|
|
572
|
-
} else {
|
|
573
|
-
const inner = `stty cols ${TERM_COLS} rows ${TERM_ROWS} 2>/dev/null; exec ${[cli, ...args].map(shEscape).join(' ')}`;
|
|
574
|
-
child = spawn(scriptBin, ['-qefc', inner, '/dev/null'], {
|
|
575
|
-
cwd: cwd || process.cwd(),
|
|
576
|
-
env: runEnv,
|
|
577
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
return {
|
|
581
|
-
kind: 'script',
|
|
582
|
-
write: (s) => { try { child.stdin.write(s); } catch { /* closed */ } },
|
|
583
|
-
onData: (fn) => {
|
|
584
|
-
child.stdout.on('data', fn);
|
|
585
|
-
child.stderr.on('data', fn);
|
|
586
|
-
},
|
|
587
|
-
onExit: (fn) => { child.on('close', (code) => fn(code ?? 0)); },
|
|
588
|
-
kill: () => {
|
|
589
|
-
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
590
|
-
},
|
|
591
|
-
pid: child.pid,
|
|
592
|
-
child,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const liveSessions = new Map();
|
|
597
|
-
|
|
598
|
-
export function closeClaudeSession(key) {
|
|
599
|
-
const sess = liveSessions.get(key);
|
|
600
|
-
if (!sess) return;
|
|
601
|
-
liveSessions.delete(key);
|
|
602
|
-
try { sess.dispose(); } catch { /* gone */ }
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
class ClaudePtySession {
|
|
606
|
-
constructor({ cli, args, cwd, env, sessionId, key }) {
|
|
607
|
-
this.cli = cli;
|
|
608
|
-
this.args = args;
|
|
609
|
-
this.cwd = cwd;
|
|
610
|
-
this.env = env;
|
|
611
|
-
this.sessionId = sessionId || '';
|
|
612
|
-
this.key = key;
|
|
613
|
-
this.dead = false;
|
|
614
|
-
this.term = new TinyTerm();
|
|
615
|
-
this.rawTail = '';
|
|
616
|
-
this.handle = spawnClaudePty({ cli, args, cwd, env });
|
|
617
|
-
this.exitCode = null;
|
|
618
|
-
this.listeners = new Set();
|
|
619
|
-
this.handle.onData((chunk) => this._ingest(chunk));
|
|
620
|
-
this.handle.onExit((code) => {
|
|
621
|
-
this.dead = true;
|
|
622
|
-
this.exitCode = code;
|
|
623
|
-
if (!this.sessionId) this.sessionId = latestClaudeSessionId(this.cwd) || this.sessionId;
|
|
624
|
-
for (const fn of this.listeners) {
|
|
625
|
-
try { fn({ kind: 'exit', exitCode: code, sessionId: this.sessionId }); } catch { /* paint */ }
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
_ingest(chunk) {
|
|
630
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
631
|
-
const sanitized = sanitizeClaudeOutput(buf);
|
|
632
|
-
if (sanitized === 'upstream HTTP 400') {
|
|
633
|
-
this.rawTail = 'upstream HTTP 400';
|
|
634
|
-
this.term = new TinyTerm();
|
|
635
|
-
this.term.write('upstream HTTP 400');
|
|
636
|
-
this._emitFold();
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const utf = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
640
|
-
const sid = utf.match(SESSION_UUID);
|
|
641
|
-
if (sid) this.sessionId = sid[0];
|
|
642
|
-
// NDJSON leak from a confused child — fold, do not dump.
|
|
643
|
-
const lines = utf.split(/\r?\n/);
|
|
644
|
-
for (const line of lines) {
|
|
645
|
-
const raw = parseNdjsonLine(line);
|
|
646
|
-
if (!raw) continue;
|
|
647
|
-
const folded = foldClaudeEvent(raw);
|
|
648
|
-
if (folded?.sessionId) this.sessionId = folded.sessionId;
|
|
649
|
-
if (folded) {
|
|
650
|
-
for (const fn of this.listeners) {
|
|
651
|
-
try { fn(folded); } catch { /* paint */ }
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
this.term.write(utf);
|
|
656
|
-
this.rawTail = (this.rawTail + utf).slice(-80_000);
|
|
657
|
-
this._emitFold();
|
|
658
|
-
}
|
|
659
|
-
_emitFold() {
|
|
660
|
-
const folded = foldTuiText(this.term.text() || this.rawTail);
|
|
661
|
-
folded.sessionId = this.sessionId;
|
|
662
|
-
folded.kind = 'tui';
|
|
663
|
-
for (const fn of this.listeners) {
|
|
664
|
-
try { fn(folded); } catch { /* paint */ }
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
onEvent(fn) {
|
|
668
|
-
this.listeners.add(fn);
|
|
669
|
-
return () => this.listeners.delete(fn);
|
|
670
|
-
}
|
|
671
|
-
writeLine(text) {
|
|
672
|
-
const line = String(text ?? '').replace(/\r?\n$/, '');
|
|
673
|
-
this.handle.write(`${line}\r`);
|
|
674
|
-
}
|
|
675
|
-
screenText() {
|
|
676
|
-
return foldTuiText(this.term.text() || this.rawTail);
|
|
677
|
-
}
|
|
678
|
-
dispose() {
|
|
679
|
-
this.dead = true;
|
|
680
|
-
try { this.handle.kill(); } catch { /* gone */ }
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function waitReady(sess, ms = 2500) {
|
|
685
|
-
return new Promise((resolve) => {
|
|
686
|
-
let done = false;
|
|
687
|
-
const finish = () => {
|
|
688
|
-
if (done) return;
|
|
689
|
-
done = true;
|
|
690
|
-
clearTimeout(t);
|
|
691
|
-
off();
|
|
692
|
-
resolve();
|
|
693
|
-
};
|
|
694
|
-
const off = sess.onEvent(() => finish());
|
|
695
|
-
const t = setTimeout(finish, ms);
|
|
696
|
-
if (sess.term.text().trim()) finish();
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function waitIdle(sess, { signal, minWait = 200 } = {}) {
|
|
701
|
-
return new Promise((resolve) => {
|
|
702
|
-
let finished = false;
|
|
703
|
-
let timer;
|
|
704
|
-
const finish = () => {
|
|
705
|
-
if (finished) return;
|
|
706
|
-
finished = true;
|
|
707
|
-
clearTimeout(timer);
|
|
708
|
-
off();
|
|
709
|
-
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
710
|
-
resolve();
|
|
711
|
-
};
|
|
712
|
-
const arm = () => {
|
|
713
|
-
clearTimeout(timer);
|
|
714
|
-
const folded = sess.screenText();
|
|
715
|
-
const idle = tuiLooksIdle(sess.term.text()) || tuiLooksIdle(folded.text);
|
|
716
|
-
const delay = idle ? 280 : 1100;
|
|
717
|
-
timer = setTimeout(finish, delay);
|
|
718
|
-
};
|
|
719
|
-
const off = sess.onEvent(() => arm());
|
|
720
|
-
const onAbort = () => finish();
|
|
721
|
-
if (signal) {
|
|
722
|
-
if (signal.aborted) { finish(); return; }
|
|
723
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
724
|
-
}
|
|
725
|
-
setTimeout(arm, minWait);
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
async function attachSession({ cli, prefixArgs = [], cwd, sessionId, model, system, env, key }) {
|
|
730
|
-
const resume = sessionId || latestClaudeSessionId(cwd);
|
|
731
|
-
const args = [...prefixArgs, ...claudeInteractiveArgs({ sessionId: resume, model, system })];
|
|
732
|
-
const sess = new ClaudePtySession({ cli, args, cwd, env, sessionId: resume, key });
|
|
733
|
-
await waitReady(sess);
|
|
734
|
-
if (!sess.sessionId) sess.sessionId = latestClaudeSessionId(cwd) || resume || '';
|
|
735
|
-
return sess;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
125
|
export async function runClaudeCode({
|
|
739
126
|
prompt,
|
|
740
127
|
cwd,
|
|
@@ -745,103 +132,100 @@ export async function runClaudeCode({
|
|
|
745
132
|
port,
|
|
746
133
|
onEvent,
|
|
747
134
|
signal,
|
|
748
|
-
sessionKey,
|
|
749
135
|
} = {}) {
|
|
750
136
|
if (runnerOverride) {
|
|
751
|
-
return runnerOverride({
|
|
752
|
-
prompt, cwd, sessionId, model, system, env, port, onEvent, signal, sessionKey,
|
|
753
|
-
});
|
|
137
|
+
return runnerOverride({ prompt, cwd, sessionId, model, system, env, port, onEvent, signal });
|
|
754
138
|
}
|
|
755
139
|
const zooEnv = claudeZooEnv(env, { port });
|
|
756
|
-
const
|
|
757
|
-
if (!
|
|
140
|
+
const cli = resolveClaudeCli(zooEnv);
|
|
141
|
+
if (!cli) {
|
|
758
142
|
return { text: CLAUDE_MISSING, error: true, paymentFailed: '', sessionId: sessionId || '', missing: true };
|
|
759
143
|
}
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const wantResume = sessionId || sess?.sessionId || '';
|
|
768
|
-
if (!sess || sess.dead) {
|
|
144
|
+
const args = claudePrintArgs({ prompt, sessionId, model, system });
|
|
145
|
+
return spawnClaudePrint({ cli, args, cwd, env: zooEnv, onEvent, signal, sessionId });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function spawnClaudePrint({ cli, args, cwd, env, onEvent, signal, sessionId }) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
let child;
|
|
769
151
|
try {
|
|
770
|
-
|
|
771
|
-
|
|
152
|
+
child = spawn(cli, args, {
|
|
153
|
+
cwd: cwd || process.cwd(),
|
|
154
|
+
env,
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
772
156
|
});
|
|
773
157
|
} catch (e) {
|
|
774
|
-
|
|
775
|
-
text:
|
|
158
|
+
resolve({
|
|
159
|
+
text: `could not launch claude: ${e.message}`,
|
|
776
160
|
error: true,
|
|
777
161
|
paymentFailed: '',
|
|
778
|
-
sessionId:
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
liveSessions.set(key, sess);
|
|
782
|
-
}
|
|
783
|
-
const off = onEvent ? sess.onEvent((ev) => {
|
|
784
|
-
if (ev.sessionId) { /* keep */ }
|
|
785
|
-
try { onEvent(ev); } catch { /* paint */ }
|
|
786
|
-
}) : () => {};
|
|
787
|
-
try {
|
|
788
|
-
if (prompt != null && String(prompt) !== '') sess.writeLine(prompt);
|
|
789
|
-
await waitIdle(sess, { signal });
|
|
790
|
-
if (sess.dead && wantResume && !signal?.aborted) {
|
|
791
|
-
// PTY died mid-turn — one resume, do not pkill anything else.
|
|
792
|
-
liveSessions.delete(key);
|
|
793
|
-
try {
|
|
794
|
-
sess = await attachSession({
|
|
795
|
-
cli, prefixArgs, cwd, sessionId: sess.sessionId || wantResume, model, system, env: zooEnv, key,
|
|
796
|
-
});
|
|
797
|
-
liveSessions.set(key, sess);
|
|
798
|
-
if (prompt != null && String(prompt) !== '') sess.writeLine(prompt);
|
|
799
|
-
await waitIdle(sess, { signal });
|
|
800
|
-
} catch { /* keep the death text */ }
|
|
162
|
+
sessionId: sessionId || '',
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
801
165
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
let
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
thinking: folded.thinking || '',
|
|
811
|
-
error: Boolean(pay) || deadErr,
|
|
812
|
-
paymentFailed: pay,
|
|
813
|
-
sessionId: sid,
|
|
814
|
-
exitCode: sess.dead ? sess.exitCode : null,
|
|
166
|
+
let stdout = '';
|
|
167
|
+
let stderr = '';
|
|
168
|
+
let sid = sessionId || '';
|
|
169
|
+
let lastText = '';
|
|
170
|
+
let lastPay = '';
|
|
171
|
+
let sawError = false;
|
|
172
|
+
const onAbort = () => {
|
|
173
|
+
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
815
174
|
};
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
175
|
+
if (signal) {
|
|
176
|
+
if (signal.aborted) onAbort();
|
|
177
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
178
|
+
}
|
|
179
|
+
const take = (chunk, which) => {
|
|
180
|
+
const s = String(chunk);
|
|
181
|
+
if (which === 'err') stderr += s;
|
|
182
|
+
else stdout += s;
|
|
183
|
+
if (which !== 'out') return;
|
|
184
|
+
const lines = stdout.split(/\r?\n/);
|
|
185
|
+
stdout = lines.pop() || '';
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const raw = parseNdjsonLine(line);
|
|
188
|
+
if (!raw) continue;
|
|
189
|
+
const folded = foldClaudeEvent(raw);
|
|
190
|
+
if (!folded) continue;
|
|
191
|
+
if (folded.sessionId) sid = folded.sessionId;
|
|
192
|
+
if (folded.kind === 'result') {
|
|
193
|
+
lastText = folded.text || lastText;
|
|
194
|
+
lastPay = folded.paymentFailed || lastPay;
|
|
195
|
+
if (folded.error) sawError = true;
|
|
196
|
+
} else if (folded.kind === 'assistant' && folded.text) {
|
|
197
|
+
lastText = folded.text;
|
|
198
|
+
}
|
|
199
|
+
try { onEvent?.(folded); } catch { /* paint must not kill the child */ }
|
|
200
|
+
}
|
|
842
201
|
};
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
202
|
+
child.stdout.on('data', (d) => take(d, 'out'));
|
|
203
|
+
child.stderr.on('data', (d) => take(d, 'err'));
|
|
204
|
+
child.on('error', (e) => {
|
|
205
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
206
|
+
resolve({
|
|
207
|
+
text: `could not launch claude: ${e.message}`,
|
|
208
|
+
error: true,
|
|
209
|
+
paymentFailed: '',
|
|
210
|
+
sessionId: sid,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
child.on('close', (code) => {
|
|
214
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
215
|
+
if (stdout.trim()) take('\n', 'out');
|
|
216
|
+
const errPay = paymentFailText(stderr);
|
|
217
|
+
const outPay = lastPay || paymentFailText(lastText) || errPay;
|
|
218
|
+
let text = lastText;
|
|
219
|
+
if (!text && stderr.trim()) text = stderr.trim().slice(0, 4000);
|
|
220
|
+
if (!text && code && code !== 0) text = `claude exited ${code}`;
|
|
221
|
+
resolve({
|
|
222
|
+
text: text || '',
|
|
223
|
+
error: sawError || (code !== 0 && code != null),
|
|
224
|
+
paymentFailed: outPay,
|
|
225
|
+
sessionId: sid,
|
|
226
|
+
stderr,
|
|
227
|
+
exitCode: code,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
847
231
|
}
|