openzoo 0.49.9 → 0.49.11

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/lib/claudecode.js CHANGED
@@ -1,72 +1,41 @@
1
1
  /**
2
- * Drive Claude Code as grokui Auto over a real PTY — the interactive TUI,
3
- * not `claude --print --output-format stream-json`.
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
- * `--print` is a one-shot JSON harness. `/context` still prints text there,
6
- * but `/agents` and `/tasks` answer "wizard removed" / "isn't available in
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 { createRequire } from 'node:module';
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 = 'openzoo-claude CLI not found. Auto is the openzoo-claude harness via OpenZoo — install it with: '
34
- + 'npx -y openzoo-claude (or: npm i -g openzoo-claude). '
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
- /** Grokui keeps these even in Auto. Everything else that looks like a slash
50
- * (Claude's /agents /tasks /context /model, plus unknown CLI slashes) goes
51
- * to the PTY. */
52
- export const GROKUI_RESERVED_SLASH = Object.freeze(['mode', 'tier', 'help', 'dir']);
53
-
54
- /** Claude Code does not know openzoo/auto. The picker Auto id must never
55
- * become `claude --model openzoo/auto` — that fails session init and
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
- const pin = claudeModelArg(model);
69
- if (pin) args.push('--model', pin);
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
- * Print-mode is gone; this only folds JSON that still leaks onto the PTY.
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 event = ev.event || {};
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: sanitizeClaudeCanvas(delta.text), sessionId };
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(sanitizeClaudeCanvas(b.text));
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.filter(Boolean).join(''),
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: pay || sanitizeClaudeCanvas(raw, { error }),
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 resolved = resolveOpenzooClaude(zooEnv);
757
- if (!resolved) {
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 cli = resolved.command;
761
- const prefixArgs = resolved.prefixArgs;
762
- if (process.platform === 'win32' && !loadNodePty()) {
763
- return { text: PTY_WINDOWS, error: true, paymentFailed: '', sessionId: sessionId || '' };
764
- }
765
- const key = sessionKey || cwd || '__default__';
766
- let sess = liveSessions.get(key);
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
- sess = await attachSession({
771
- cli, prefixArgs, cwd, sessionId: wantResume, model, system, env: zooEnv, key,
152
+ child = spawn(cli, args, {
153
+ cwd: cwd || process.cwd(),
154
+ env,
155
+ stdio: ['ignore', 'pipe', 'pipe'],
772
156
  });
773
157
  } catch (e) {
774
- return {
775
- text: e.code === 'PTY_WINDOWS' ? PTY_WINDOWS : `could not launch claude: ${e.message}`,
158
+ resolve({
159
+ text: `could not launch claude: ${e.message}`,
776
160
  error: true,
777
161
  paymentFailed: '',
778
- sessionId: wantResume,
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
- const folded = sess.screenText();
803
- const sid = sess.sessionId || latestClaudeSessionId(cwd) || wantResume;
804
- const pay = folded.paymentFailed || paymentFailText(folded.text);
805
- const deadErr = Boolean(sess.dead && sess.exitCode && sess.exitCode !== 0);
806
- let text = pay || folded.text || '';
807
- if (!pay) text = sanitizeClaudeCanvas(text, { error: deadErr }) || text;
808
- return {
809
- text,
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
- } finally {
817
- off();
818
- }
819
- }
820
-
821
- /** Test helper: one-shot PTY to a given binary (same attach as Auto). */
822
- export async function spawnClaudeInteractive({
823
- cli, args, cwd, env, onEvent, signal, prompt = '',
824
- }) {
825
- const sess = new ClaudePtySession({
826
- cli, args: args || claudeInteractiveArgs({}), cwd, env: env || process.env, key: `test-${Date.now()}`,
827
- });
828
- const off = onEvent ? sess.onEvent(onEvent) : () => {};
829
- try {
830
- await waitReady(sess, 1500);
831
- if (prompt) sess.writeLine(prompt);
832
- await waitIdle(sess, { signal, minWait: 80 });
833
- const folded = sess.screenText();
834
- return {
835
- text: folded.text,
836
- thinking: folded.thinking,
837
- tools: folded.tools,
838
- sessionId: sess.sessionId,
839
- error: Boolean(sess.dead && sess.exitCode && sess.exitCode !== 0),
840
- paymentFailed: folded.paymentFailed,
841
- kind: sess.handle.kind,
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
- } finally {
844
- off();
845
- sess.dispose();
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
  }