openzoo 0.49.7 → 0.49.9
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 +39 -1
- package/bin/openzoo.js +3 -2
- package/lib/claudecode.js +847 -0
- package/lib/grokui.mjs +1351 -226
- package/lib/launch.js +244 -152
- package/lib/livestatus.js +6 -4
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/models.js +221 -44
- package/lib/package.json +3 -0
- package/lib/pay.js +67 -3
- package/lib/podagent.mjs +84 -28
- package/lib/proxy.js +144 -56
- package/lib/racesettle.js +127 -0
- package/lib/relay.js +275 -0
- package/lib/runguard.js +31 -0
- package/lib/spill.js +9 -1
- package/lib/think.js +126 -0
- package/package.json +5 -4
- package/vendor/modelroute/CURRENT_STATE.md +132 -0
- package/vendor/modelroute/HANDOFF.md +159 -0
- package/vendor/modelroute/catalog.json +1 -0
- package/vendor/modelroute/holographic_modelroute.py +809 -0
- package/vendor/modelroute/outcomes.json +1566 -0
- package/vendor/modelroute/router.json +1 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive Claude Code as grokui Auto over a real PTY — the interactive TUI,
|
|
3
|
+
* not `claude --print --output-format stream-json`.
|
|
4
|
+
*
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
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';
|
|
27
|
+
|
|
28
|
+
export const AUTO_CLAUDE_SYSTEM = 'You are grokui Auto, paid per call through the local OpenZoo proxy (x402 on :8402). '
|
|
29
|
+
+ 'Use your native tools (Bash, Read, Write, Edit, Glob, Grep) to do the work in this working directory. '
|
|
30
|
+
+ 'Do not curl localhost:8402/v1/chat/completions and do not emit RUN:/WRITE:/DONE: text directives — you already have real tools. '
|
|
31
|
+
+ 'Do not ask the user to type continue.';
|
|
32
|
+
|
|
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). '
|
|
35
|
+
+ 'No Anthropic login: `openzoo claude` already points ANTHROPIC_BASE_URL at the local proxy.';
|
|
36
|
+
|
|
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
|
+
let runnerOverride = null;
|
|
45
|
+
export function setClaudeRunnerForTest(fn) {
|
|
46
|
+
runnerOverride = typeof fn === 'function' ? fn : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
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'];
|
|
65
|
+
const sys = system === undefined ? AUTO_CLAUDE_SYSTEM : system;
|
|
66
|
+
if (sys) args.push('--append-system-prompt', sys);
|
|
67
|
+
if (sessionId) args.push('--resume', String(sessionId));
|
|
68
|
+
const pin = claudeModelArg(model);
|
|
69
|
+
if (pin) args.push('--model', pin);
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toolStatusLine(name, input) {
|
|
74
|
+
const n = String(name || 'tool');
|
|
75
|
+
const i = input && typeof input === 'object' ? input : {};
|
|
76
|
+
if (n === 'Write' || n === 'Edit' || n === 'Read' || n === 'NotebookEdit') {
|
|
77
|
+
return `${n} ${i.file_path || i.path || ''}`.trim();
|
|
78
|
+
}
|
|
79
|
+
if (n === 'Bash') return `Bash ${String(i.command || i.cmd || '').slice(0, 80)}`.trim();
|
|
80
|
+
if (n === 'Glob') return `Glob ${i.pattern || ''}`.trim();
|
|
81
|
+
if (n === 'Grep') return `Grep ${i.pattern || ''}`.trim();
|
|
82
|
+
if (n === 'Task') return `Task ${i.description || i.prompt || ''}`.trim();
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function paymentFailText(text) {
|
|
87
|
+
const s = String(text || '');
|
|
88
|
+
if (/\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(s)) {
|
|
89
|
+
return s.includes('HTTP 402') || /payment/i.test(s)
|
|
90
|
+
? s
|
|
91
|
+
: `(payment required — HTTP 402, the wallet is empty.) ${s}`.trim();
|
|
92
|
+
}
|
|
93
|
+
if (/\b(?:payment failed|HTTP 402|payment required)\b/i.test(s)) return s;
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
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
|
+
/**
|
|
187
|
+
* 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.
|
|
189
|
+
*/
|
|
190
|
+
export function foldClaudeEvent(ev) {
|
|
191
|
+
if (!ev || typeof ev !== 'object') return null;
|
|
192
|
+
const sessionId = ev.session_id || ev.sessionId;
|
|
193
|
+
if (ev.type === 'system' && ev.subtype === 'init') {
|
|
194
|
+
return { kind: 'init', sessionId, model: ev.model, tools: ev.tools || [] };
|
|
195
|
+
}
|
|
196
|
+
if (ev.type === 'stream_event') {
|
|
197
|
+
const event = ev.event || {};
|
|
198
|
+
const delta = event.delta;
|
|
199
|
+
const block = event.content_block || event.contentBlock;
|
|
200
|
+
if (delta?.type === 'thinking_delta' && delta.thinking) {
|
|
201
|
+
return { kind: 'think', text: delta.thinking, sessionId };
|
|
202
|
+
}
|
|
203
|
+
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 };
|
|
219
|
+
}
|
|
220
|
+
return { kind: 'partial', sessionId };
|
|
221
|
+
}
|
|
222
|
+
if (ev.type === 'assistant' && ev.message) {
|
|
223
|
+
const blocks = Array.isArray(ev.message.content) ? ev.message.content : [];
|
|
224
|
+
const thinking = [];
|
|
225
|
+
const text = [];
|
|
226
|
+
const tools = [];
|
|
227
|
+
for (const b of blocks) {
|
|
228
|
+
if (!b || typeof b !== 'object') continue;
|
|
229
|
+
if (b.type === 'thinking' && b.thinking) thinking.push(b.thinking);
|
|
230
|
+
else if (b.type === 'text' && b.text) text.push(sanitizeClaudeCanvas(b.text));
|
|
231
|
+
else if (b.type === 'tool_use') tools.push({ name: b.name, input: b.input || {} });
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: 'assistant',
|
|
235
|
+
thinking: thinking.join('\n'),
|
|
236
|
+
text: text.filter(Boolean).join(''),
|
|
237
|
+
tools,
|
|
238
|
+
sessionId,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (ev.type === 'result') {
|
|
242
|
+
const raw = ev.result != null ? String(ev.result) : (ev.error != null ? String(ev.error) : '');
|
|
243
|
+
const pay = paymentFailText(raw);
|
|
244
|
+
const error = Boolean(ev.is_error || ev.subtype === 'error' || ev.subtype === 'error_during_execution');
|
|
245
|
+
return {
|
|
246
|
+
kind: 'result',
|
|
247
|
+
text: pay || sanitizeClaudeCanvas(raw, { error }),
|
|
248
|
+
error,
|
|
249
|
+
paymentFailed: pay,
|
|
250
|
+
sessionId,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { kind: ev.type || 'other', sessionId };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseNdjsonLine(line) {
|
|
257
|
+
const s = String(line || '').trim();
|
|
258
|
+
if (!s) return null;
|
|
259
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
260
|
+
}
|
|
261
|
+
|
|
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
|
+
export async function runClaudeCode({
|
|
739
|
+
prompt,
|
|
740
|
+
cwd,
|
|
741
|
+
sessionId,
|
|
742
|
+
model,
|
|
743
|
+
system,
|
|
744
|
+
env = process.env,
|
|
745
|
+
port,
|
|
746
|
+
onEvent,
|
|
747
|
+
signal,
|
|
748
|
+
sessionKey,
|
|
749
|
+
} = {}) {
|
|
750
|
+
if (runnerOverride) {
|
|
751
|
+
return runnerOverride({
|
|
752
|
+
prompt, cwd, sessionId, model, system, env, port, onEvent, signal, sessionKey,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const zooEnv = claudeZooEnv(env, { port });
|
|
756
|
+
const resolved = resolveOpenzooClaude(zooEnv);
|
|
757
|
+
if (!resolved) {
|
|
758
|
+
return { text: CLAUDE_MISSING, error: true, paymentFailed: '', sessionId: sessionId || '', missing: true };
|
|
759
|
+
}
|
|
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) {
|
|
769
|
+
try {
|
|
770
|
+
sess = await attachSession({
|
|
771
|
+
cli, prefixArgs, cwd, sessionId: wantResume, model, system, env: zooEnv, key,
|
|
772
|
+
});
|
|
773
|
+
} catch (e) {
|
|
774
|
+
return {
|
|
775
|
+
text: e.code === 'PTY_WINDOWS' ? PTY_WINDOWS : `could not launch claude: ${e.message}`,
|
|
776
|
+
error: true,
|
|
777
|
+
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 */ }
|
|
801
|
+
}
|
|
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,
|
|
815
|
+
};
|
|
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,
|
|
842
|
+
};
|
|
843
|
+
} finally {
|
|
844
|
+
off();
|
|
845
|
+
sess.dispose();
|
|
846
|
+
}
|
|
847
|
+
}
|