lazyclaw 6.3.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"lint:size": "node scripts/lint-file-size.mjs",
|
|
36
36
|
"lint:pack": "node scripts/check-pack.mjs",
|
|
37
37
|
"test:bench": "node scripts/bench-providers.mjs",
|
|
38
|
+
"test:bench:cli": "node scripts/bench-claude-cli.mjs",
|
|
38
39
|
"test:bench:index": "node --test tests/index_store.bench.mjs",
|
|
39
40
|
"test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
|
|
40
41
|
"migrate:v5": "node scripts/migrate-v5.mjs"
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"sandbox/",
|
|
69
70
|
"skills_install.mjs",
|
|
70
71
|
"skills_curator.mjs",
|
|
72
|
+
"skills/",
|
|
71
73
|
"cron.mjs",
|
|
72
74
|
"loop-engine.mjs",
|
|
73
75
|
"loops.mjs",
|
package/providers/anthropic.mjs
CHANGED
|
@@ -47,6 +47,63 @@ class RateLimitError extends Error {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
class TimeoutError extends Error {
|
|
51
|
+
constructor(idleMs) {
|
|
52
|
+
super(`anthropic: idle timeout — no stream activity for ${idleMs}ms`);
|
|
53
|
+
this.name = 'TimeoutError';
|
|
54
|
+
this.code = 'TIMEOUT';
|
|
55
|
+
this.idleMs = idleMs;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
60
|
+
|
|
61
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
62
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
63
|
+
function resolveIdleTimeoutMs(opts) {
|
|
64
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
65
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
66
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
67
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
71
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
72
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
73
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
74
|
+
// `controller` is aborted on idle expiry so the real connection tears down;
|
|
75
|
+
// the caller's signal is forwarded into the same controller upstream.
|
|
76
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
77
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
let timer;
|
|
81
|
+
const idle = new Promise((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
controller.abort();
|
|
84
|
+
reject(new TimeoutError(idleMs));
|
|
85
|
+
}, idleMs);
|
|
86
|
+
});
|
|
87
|
+
let step;
|
|
88
|
+
try {
|
|
89
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
if (step.done) return;
|
|
94
|
+
yield step.value;
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
98
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
99
|
+
// would re-hang us. We aborted the controller (tearing down the real
|
|
100
|
+
// connection) already; just nudge the iterator and move on.
|
|
101
|
+
if (typeof iterator.return === 'function') {
|
|
102
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
50
107
|
class ApiError extends Error {
|
|
51
108
|
constructor(status, body) {
|
|
52
109
|
super(`anthropic api ${status}: ${body.slice(0, 200)}`);
|
|
@@ -134,7 +191,7 @@ export const anthropicProvider = {
|
|
|
134
191
|
const fetchFn = opts.fetch || globalThis.fetch;
|
|
135
192
|
if (!fetchFn) throw new Error('anthropic: no fetch implementation available');
|
|
136
193
|
|
|
137
|
-
const model = opts.model || 'claude-opus-4-
|
|
194
|
+
const model = opts.model || 'claude-opus-4-8';
|
|
138
195
|
const apiMessages = messages
|
|
139
196
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
140
197
|
.map(m => ({ role: m.role, content: String(m.content ?? '') }));
|
|
@@ -198,6 +255,21 @@ export const anthropicProvider = {
|
|
|
198
255
|
// surfaces immediately rather than waiting for the next chunk.
|
|
199
256
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
200
257
|
|
|
258
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
259
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
260
|
+
// connection (or a user cancel) tears down the real socket.
|
|
261
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
262
|
+
const idleController = new AbortController();
|
|
263
|
+
const fetchSignal = opts.signal
|
|
264
|
+
? (typeof AbortSignal.any === 'function'
|
|
265
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
266
|
+
: idleController.signal)
|
|
267
|
+
: idleController.signal;
|
|
268
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
269
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
270
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
const res = await fetchFn('https://api.anthropic.com/v1/messages', {
|
|
202
274
|
method: 'POST',
|
|
203
275
|
headers: {
|
|
@@ -206,7 +278,7 @@ export const anthropicProvider = {
|
|
|
206
278
|
'anthropic-version': ANTHROPIC_VERSION,
|
|
207
279
|
},
|
|
208
280
|
body: JSON.stringify(body),
|
|
209
|
-
signal:
|
|
281
|
+
signal: fetchSignal,
|
|
210
282
|
});
|
|
211
283
|
|
|
212
284
|
if (!res.ok) {
|
|
@@ -232,7 +304,25 @@ export const anthropicProvider = {
|
|
|
232
304
|
// We collect both and emit a single opts.onUsage call right before
|
|
233
305
|
// we return on message_stop.
|
|
234
306
|
let usage = null;
|
|
235
|
-
|
|
307
|
+
let lastStop = null;
|
|
308
|
+
// Fire onUsage + (if truncated) onTruncated EXACTLY ONCE — from message_stop
|
|
309
|
+
// or, if the stream ends without it after usage was captured, the post-loop
|
|
310
|
+
// flush. stop_reason 'max_tokens' is Anthropic's hard output-cut signal.
|
|
311
|
+
let finalized = false;
|
|
312
|
+
const finalize = () => {
|
|
313
|
+
if (finalized) return;
|
|
314
|
+
finalized = true;
|
|
315
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
316
|
+
try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
|
|
317
|
+
}
|
|
318
|
+
if (lastStop === 'max_tokens' && typeof opts.onTruncated === 'function') {
|
|
319
|
+
try { opts.onTruncated('max_tokens'); } catch { /* ditto */ }
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
try {
|
|
323
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
324
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
325
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
236
326
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
237
327
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
238
328
|
let consumed = 0;
|
|
@@ -254,6 +344,7 @@ export const anthropicProvider = {
|
|
|
254
344
|
} else if (frame.event === 'message_delta' && frame.data) {
|
|
255
345
|
try {
|
|
256
346
|
const obj = JSON.parse(frame.data);
|
|
347
|
+
if (obj?.delta?.stop_reason) lastStop = obj.delta.stop_reason;
|
|
257
348
|
const u = obj?.usage;
|
|
258
349
|
if (u && usage) {
|
|
259
350
|
// message_delta carries the final output_tokens — overwrite
|
|
@@ -307,9 +398,7 @@ export const anthropicProvider = {
|
|
|
307
398
|
}
|
|
308
399
|
} catch { /* skip malformed */ }
|
|
309
400
|
} else if (frame.event === 'message_stop') {
|
|
310
|
-
|
|
311
|
-
try { opts.onUsage(usage); } catch { /* never let a callback abort */ }
|
|
312
|
-
}
|
|
401
|
+
finalize();
|
|
313
402
|
return;
|
|
314
403
|
} else if (frame.event === 'error' && frame.data) {
|
|
315
404
|
let parsed = null;
|
|
@@ -320,6 +409,12 @@ export const anthropicProvider = {
|
|
|
320
409
|
}
|
|
321
410
|
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
322
411
|
}
|
|
412
|
+
} finally {
|
|
413
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
414
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
finalize(); // flush captured usage/truncation if the stream ended without message_stop
|
|
323
418
|
// Flush any pending bytes the streaming decoder was still holding.
|
|
324
419
|
const tail = decoder.decode();
|
|
325
420
|
if (tail) buffer += tail;
|
package/providers/cache.mjs
CHANGED
|
@@ -47,7 +47,15 @@ function hashKey(messages, model, opts) {
|
|
|
47
47
|
messages,
|
|
48
48
|
model: model || null,
|
|
49
49
|
thinking: opts?.thinking || null,
|
|
50
|
-
system: opts
|
|
50
|
+
// The system prompt can arrive three ways: opts.system, the static/volatile
|
|
51
|
+
// split (opts.systemStatic / opts.systemVolatile — what the REPL caller
|
|
52
|
+
// uses), or an embedded role:system message. Fold ALL of them into the key
|
|
53
|
+
// so two calls that differ only in their static system can't collide and
|
|
54
|
+
// serve one another's cached reply. (When system lives in messages it is
|
|
55
|
+
// already in the key via `messages`; the explicit field is belt-and-suspenders.)
|
|
56
|
+
system: opts?.system || (Array.isArray(messages) ? messages.find?.((m) => m?.role === 'system')?.content : null) || null,
|
|
57
|
+
systemStatic: opts?.systemStatic || null,
|
|
58
|
+
systemVolatile: opts?.systemVolatile || null,
|
|
51
59
|
tools: opts?.tools || null,
|
|
52
60
|
toolChoice: opts?.toolChoice || null,
|
|
53
61
|
};
|
package/providers/claude_cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
21
|
import { spawnSandboxed } from '../sandbox.mjs';
|
|
22
|
+
import { classifyCliExit } from './cli_error.mjs';
|
|
22
23
|
|
|
23
24
|
class AbortError extends Error {
|
|
24
25
|
constructor(message = 'aborted') {
|
|
@@ -40,7 +41,12 @@ class CliExitError extends Error {
|
|
|
40
41
|
constructor(code, signal, stderr) {
|
|
41
42
|
super(`claude CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
42
43
|
this.name = 'ClaudeCliExitError';
|
|
43
|
-
|
|
44
|
+
// A transient upstream throttle (claude's "Server temporarily limiting
|
|
45
|
+
// requests", overload, 429/5xx) maps to a retriable RATE_LIMIT so
|
|
46
|
+
// withRateLimitRetry retries it; a genuine usage cap stays CLI_EXIT.
|
|
47
|
+
const cls = classifyCliExit(stderr);
|
|
48
|
+
this.code = cls.code;
|
|
49
|
+
if (cls.retryAfterMs !== undefined) this.retryAfterMs = cls.retryAfterMs;
|
|
44
50
|
this.exitCode = code;
|
|
45
51
|
this.signal = signal;
|
|
46
52
|
this.stderr = stderr;
|
|
@@ -51,12 +57,15 @@ class CliExitError extends Error {
|
|
|
51
57
|
// form `claude --model` actually accepts. The Python dashboard ran into
|
|
52
58
|
// the same issue (FF1) — passing the full id silently hangs the CLI.
|
|
53
59
|
const _CLI_MODEL_ALIASES = {
|
|
60
|
+
'claude-fable-5': 'fable',
|
|
61
|
+
'claude-opus-4-8': 'opus',
|
|
54
62
|
'claude-opus-4-7': 'opus',
|
|
55
63
|
'claude-opus-4-6': 'opus',
|
|
56
64
|
'claude-sonnet-4-6': 'sonnet',
|
|
57
65
|
'claude-sonnet-4-5': 'sonnet',
|
|
58
66
|
'claude-haiku-4-5': 'haiku',
|
|
59
67
|
'claude-haiku-4-5-20251001': 'haiku',
|
|
68
|
+
fable: 'fable',
|
|
60
69
|
opus: 'opus',
|
|
61
70
|
sonnet: 'sonnet',
|
|
62
71
|
haiku: 'haiku',
|
|
@@ -107,6 +116,37 @@ function extractTextDelta(obj) {
|
|
|
107
116
|
return '';
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// Build the `claude` argv. By DEFAULT lazyclaw runs claude LEAN: lazyclaw
|
|
120
|
+
// supplies its own system prompt, so claude must NOT inherit the user's global
|
|
121
|
+
// CLAUDE.md / skills / hooks / MCP servers. Loading them made every turn pull
|
|
122
|
+
// ~180k tokens (measured) and let Claude Code act on the user's personal config
|
|
123
|
+
// instead of lazyclaw's prompt — slow and off-task. `--setting-sources ''`
|
|
124
|
+
// loads none of user/project/local; `--strict-mcp-config` (no --mcp-config)
|
|
125
|
+
// loads no MCP servers. Pass opts.lean=false to restore the full environment.
|
|
126
|
+
export function buildArgs(prompt, opts = {}) {
|
|
127
|
+
const args = [
|
|
128
|
+
'-p', prompt,
|
|
129
|
+
'--output-format', 'stream-json',
|
|
130
|
+
'--include-partial-messages',
|
|
131
|
+
'--verbose',
|
|
132
|
+
];
|
|
133
|
+
if (opts.lean !== false) {
|
|
134
|
+
args.push('--setting-sources', '', '--strict-mcp-config');
|
|
135
|
+
}
|
|
136
|
+
// Bound Claude Code's internal autonomous agent loop. A plain chat/worker
|
|
137
|
+
// completion is ONE turn with NO built-in tools — without this a single
|
|
138
|
+
// question can trigger Claude Code's full Read/Grep/Bash exploration loop
|
|
139
|
+
// (measured 6-11 internal model calls, 90-126s) instead of just answering.
|
|
140
|
+
// Callers that genuinely want agentic behavior raise maxTurns and pass a
|
|
141
|
+
// tools whitelist (the agentic/team path uses the tool-use adapter instead).
|
|
142
|
+
const maxTurns = opts.maxTurns == null ? 1 : opts.maxTurns;
|
|
143
|
+
if (maxTurns > 0) args.push('--max-turns', String(maxTurns));
|
|
144
|
+
args.push('--tools', opts.tools == null ? '' : String(opts.tools));
|
|
145
|
+
const modelAlias = resolveModelAlias(opts.model);
|
|
146
|
+
if (modelAlias) args.push('--model', modelAlias);
|
|
147
|
+
return args;
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
export const claudeCliProvider = {
|
|
111
151
|
name: 'claude-cli',
|
|
112
152
|
/**
|
|
@@ -122,17 +162,39 @@ export const claudeCliProvider = {
|
|
|
122
162
|
*/
|
|
123
163
|
async *sendMessage(messages, opts = {}) {
|
|
124
164
|
const bin = opts.bin || 'claude';
|
|
165
|
+
|
|
166
|
+
// Opt-in persistent stream-json session: reuse ONE warm `claude` per
|
|
167
|
+
// conversation (the harness boots once, not every turn). Send only the NEW
|
|
168
|
+
// user turn — the session holds prior context server-side. We must NOT fall
|
|
169
|
+
// back to a fresh one-shot spawn once a chunk has been yielded (that would
|
|
170
|
+
// re-run the turn), so only fall through on a PRE-yield failure.
|
|
171
|
+
if (opts.persistent && opts.sessionKey) {
|
|
172
|
+
const last = [...messages].reverse().find((m) => m && m.role === 'user');
|
|
173
|
+
if (last && String(last.content).trim()) {
|
|
174
|
+
const { getSession } = await import('./claude_cli_session.mjs');
|
|
175
|
+
const session = getSession(opts.sessionKey, {
|
|
176
|
+
bin, model: opts.model, cwd: opts.cwd, lean: opts.lean,
|
|
177
|
+
maxTurns: opts.maxTurns, tools: opts.tools,
|
|
178
|
+
system: opts.sessionSystem || opts.system || messages.find((m) => m.role === 'system')?.content,
|
|
179
|
+
});
|
|
180
|
+
let yielded = false;
|
|
181
|
+
try {
|
|
182
|
+
for await (const chunk of session.send(String(last.content), { signal: opts.signal, onUsage: opts.onUsage })) {
|
|
183
|
+
yielded = true;
|
|
184
|
+
yield chunk;
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (yielded || err?.code === 'ABORT') throw err; // can't re-run mid-stream
|
|
189
|
+
// pre-yield session failure → fall through to the one-shot spawn below
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
125
194
|
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
126
195
|
if (!prompt) return;
|
|
127
196
|
|
|
128
|
-
const args =
|
|
129
|
-
'-p', prompt,
|
|
130
|
-
'--output-format', 'stream-json',
|
|
131
|
-
'--include-partial-messages',
|
|
132
|
-
'--verbose',
|
|
133
|
-
];
|
|
134
|
-
const modelAlias = resolveModelAlias(opts.model);
|
|
135
|
-
if (modelAlias) args.push('--model', modelAlias);
|
|
197
|
+
const args = buildArgs(prompt, opts);
|
|
136
198
|
|
|
137
199
|
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
138
200
|
|
|
@@ -188,6 +250,34 @@ export const claudeCliProvider = {
|
|
|
188
250
|
});
|
|
189
251
|
});
|
|
190
252
|
|
|
253
|
+
// Accumulate per-turn usage from `assistant` events. The streaming `result`
|
|
254
|
+
// event reports ZERO token usage (verified on claude 2.1.185), so reading it
|
|
255
|
+
// dropped input/output counts to 0; the truthful per-turn usage rides the
|
|
256
|
+
// `assistant` message event. Emit on the result event (which carries cost),
|
|
257
|
+
// summing across assistant events so a multi-turn run reports its total.
|
|
258
|
+
const _usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, saw: false };
|
|
259
|
+
const _accumulateUsage = (obj) => {
|
|
260
|
+
if (obj?.type === 'assistant' && obj.message?.usage) {
|
|
261
|
+
const u = obj.message.usage;
|
|
262
|
+
_usage.input += u.input_tokens || 0;
|
|
263
|
+
_usage.output += u.output_tokens || 0;
|
|
264
|
+
_usage.cacheCreate += u.cache_creation_input_tokens || 0;
|
|
265
|
+
_usage.cacheRead += u.cache_read_input_tokens || 0;
|
|
266
|
+
_usage.saw = true;
|
|
267
|
+
}
|
|
268
|
+
if (obj?.type === 'result' && typeof opts.onUsage === 'function') {
|
|
269
|
+
try {
|
|
270
|
+
opts.onUsage({
|
|
271
|
+
inputTokens: _usage.saw ? _usage.input : (obj.usage?.input_tokens || 0),
|
|
272
|
+
outputTokens: _usage.saw ? _usage.output : (obj.usage?.output_tokens || 0),
|
|
273
|
+
cacheCreationInputTokens: _usage.cacheCreate,
|
|
274
|
+
cacheReadInputTokens: _usage.cacheRead,
|
|
275
|
+
totalCostUsd: obj.total_cost_usd || 0,
|
|
276
|
+
});
|
|
277
|
+
} catch (_) { /* never fail the stream on a usage callback */ }
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
191
281
|
try {
|
|
192
282
|
for await (const chunk of proc.stdout) {
|
|
193
283
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
@@ -201,26 +291,17 @@ export const claudeCliProvider = {
|
|
|
201
291
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
202
292
|
const text = extractTextDelta(obj);
|
|
203
293
|
if (text) yield text;
|
|
204
|
-
|
|
205
|
-
// Last event of a successful run carries usage + cost.
|
|
206
|
-
if (typeof opts.onUsage === 'function') {
|
|
207
|
-
try {
|
|
208
|
-
opts.onUsage({
|
|
209
|
-
inputTokens: obj.usage?.input_tokens || 0,
|
|
210
|
-
outputTokens: obj.usage?.output_tokens || 0,
|
|
211
|
-
totalCostUsd: obj.total_cost_usd || 0,
|
|
212
|
-
});
|
|
213
|
-
} catch (_) { /* never fail the stream on usage callback */ }
|
|
214
|
-
}
|
|
215
|
-
}
|
|
294
|
+
_accumulateUsage(obj);
|
|
216
295
|
}
|
|
217
296
|
}
|
|
218
|
-
// Drain trailing buffered line
|
|
297
|
+
// Drain trailing buffered line — handle usage too, so a result line that
|
|
298
|
+
// isn't newline-terminated still reports tokens + cost.
|
|
219
299
|
if (buffer.trim()) {
|
|
220
300
|
try {
|
|
221
301
|
const obj = JSON.parse(buffer.trim());
|
|
222
302
|
const text = extractTextDelta(obj);
|
|
223
303
|
if (text) yield text;
|
|
304
|
+
_accumulateUsage(obj);
|
|
224
305
|
} catch (_) { /* incomplete tail — drop */ }
|
|
225
306
|
}
|
|
226
307
|
// Wait for either a clean exit or an async spawn error. On ENOENT
|
|
@@ -245,3 +326,4 @@ export const claudeCliProvider = {
|
|
|
245
326
|
};
|
|
246
327
|
|
|
247
328
|
export { CliMissingError, CliExitError, AbortError, resolveModelAlias, buildPrompt };
|
|
329
|
+
// buildArgs is also exported at its definition (lean-flag seam).
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Persistent claude stream-json session.
|
|
2
|
+
//
|
|
3
|
+
// lazyclaw's default claude-cli path spawns a fresh `claude -p` per turn, so the
|
|
4
|
+
// Claude Code agent harness cold-boots and re-loads its base context EVERY turn
|
|
5
|
+
// (measured ~1.6-2.7s + ~24k tokens per spawn). Claude Code also supports a
|
|
6
|
+
// realtime streaming-input mode (`--input-format stream-json`): keep ONE child
|
|
7
|
+
// alive per conversation, write each user turn as a JSON line to its stdin, read
|
|
8
|
+
// the reply from stdout. The harness boots ONCE per session and stays warm — the
|
|
9
|
+
// same way the Hermes upstream stays fast on the same $0 Pro subscription.
|
|
10
|
+
// Measured here: turn 1 ~4.4s, turn 2 ~2.2s (boot amortized).
|
|
11
|
+
//
|
|
12
|
+
// Lifecycle: sessions are keyed by a conversation id, serialise one turn at a
|
|
13
|
+
// time, evict on child exit/crash (next getSession respawns), kill on abort
|
|
14
|
+
// (so a half-generated turn can't leak into the next), and idle-timeout teardown.
|
|
15
|
+
// Callers keep the existing one-shot providers/claude_cli.mjs path as the
|
|
16
|
+
// fallback for stateless calls and when this mode is unavailable.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { resolveModelAlias, AbortError } from './claude_cli.mjs';
|
|
20
|
+
|
|
21
|
+
const SESSIONS = new Map();
|
|
22
|
+
const DEFAULT_IDLE_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
function extractTextDelta(obj) {
|
|
25
|
+
if (!obj || typeof obj !== 'object' || obj.type !== 'stream_event') return '';
|
|
26
|
+
const ev = obj.event || {};
|
|
27
|
+
if (ev.type === 'content_block_delta' && ev.delta && ev.delta.type === 'text_delta' && typeof ev.delta.text === 'string') {
|
|
28
|
+
return ev.delta.text;
|
|
29
|
+
}
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ClaudeSession {
|
|
34
|
+
constructor(key, opts = {}) {
|
|
35
|
+
this.key = key;
|
|
36
|
+
this.opts = opts;
|
|
37
|
+
this.idleMs = opts.idleMs || DEFAULT_IDLE_MS;
|
|
38
|
+
this._spawn = opts._spawn || spawn;
|
|
39
|
+
this.busy = false;
|
|
40
|
+
this.alive = true;
|
|
41
|
+
this.buffer = '';
|
|
42
|
+
this._spawnChild();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_spawnChild() {
|
|
46
|
+
const o = this.opts;
|
|
47
|
+
const args = [
|
|
48
|
+
'--print', '--input-format', 'stream-json', '--output-format', 'stream-json',
|
|
49
|
+
'--include-partial-messages', '--verbose',
|
|
50
|
+
];
|
|
51
|
+
if (o.lean !== false) args.push('--setting-sources', '', '--strict-mcp-config');
|
|
52
|
+
const mt = o.maxTurns == null ? 1 : o.maxTurns;
|
|
53
|
+
if (mt > 0) args.push('--max-turns', String(mt));
|
|
54
|
+
args.push('--tools', o.tools == null ? '' : String(o.tools));
|
|
55
|
+
const model = resolveModelAlias(o.model);
|
|
56
|
+
if (model) args.push('--model', model);
|
|
57
|
+
if (o.system && String(o.system).trim()) args.push('--append-system-prompt', String(o.system));
|
|
58
|
+
|
|
59
|
+
this.proc = this._spawn(o.bin || process.env.LAZYCLAW_CLAUDE_BIN || 'claude', args, {
|
|
60
|
+
cwd: o.cwd || process.cwd(),
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
});
|
|
63
|
+
try { this.proc.stdout.setEncoding('utf8'); } catch { /* fake child in tests */ }
|
|
64
|
+
const evict = () => {
|
|
65
|
+
this.alive = false;
|
|
66
|
+
clearTimeout(this._idle);
|
|
67
|
+
if (SESSIONS.get(this.key) === this) SESSIONS.delete(this.key);
|
|
68
|
+
};
|
|
69
|
+
this.proc.on('exit', evict);
|
|
70
|
+
this.proc.on('error', evict);
|
|
71
|
+
this._touch();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_touch() {
|
|
75
|
+
clearTimeout(this._idle);
|
|
76
|
+
this._idle = setTimeout(() => this.close(), this.idleMs);
|
|
77
|
+
if (this._idle && typeof this._idle.unref === 'function') this._idle.unref();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stream one turn. Yields text deltas until the matching `result` event.
|
|
81
|
+
async *send(userText, { signal, onUsage } = {}) {
|
|
82
|
+
if (this.busy) throw new Error('claude session busy — sends must be serialised per session');
|
|
83
|
+
if (!this.alive) throw new Error('claude session is not alive');
|
|
84
|
+
this.busy = true;
|
|
85
|
+
this._touch();
|
|
86
|
+
|
|
87
|
+
const queue = [];
|
|
88
|
+
let done = false;
|
|
89
|
+
let error = null;
|
|
90
|
+
let wake = null;
|
|
91
|
+
const wait = () => new Promise((r) => { wake = r; });
|
|
92
|
+
const onData = (d) => {
|
|
93
|
+
this.buffer += d;
|
|
94
|
+
let nl;
|
|
95
|
+
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
96
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
97
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
98
|
+
if (!line) continue;
|
|
99
|
+
let obj;
|
|
100
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
101
|
+
const t = extractTextDelta(obj);
|
|
102
|
+
if (t) { queue.push(t); if (wake) wake(); }
|
|
103
|
+
if (obj.type === 'result') {
|
|
104
|
+
if (typeof onUsage === 'function' && obj.usage) {
|
|
105
|
+
try {
|
|
106
|
+
onUsage({
|
|
107
|
+
inputTokens: obj.usage.input_tokens || 0,
|
|
108
|
+
outputTokens: obj.usage.output_tokens || 0,
|
|
109
|
+
totalCostUsd: obj.total_cost_usd || 0,
|
|
110
|
+
});
|
|
111
|
+
} catch { /* never fail a turn on a usage callback */ }
|
|
112
|
+
}
|
|
113
|
+
done = true; if (wake) wake();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const onExit = () => { if (!error) error = new Error('claude session exited mid-turn'); done = true; if (wake) wake(); };
|
|
118
|
+
this.proc.stdout.on('data', onData);
|
|
119
|
+
this.proc.once('exit', onExit);
|
|
120
|
+
|
|
121
|
+
let aborted = false;
|
|
122
|
+
try {
|
|
123
|
+
this.proc.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: String(userText) } }) + '\n');
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (queue.length) { yield queue.shift(); continue; }
|
|
126
|
+
if (done) break;
|
|
127
|
+
if (signal && signal.aborted) { aborted = true; error = new AbortError('aborted mid-session'); break; }
|
|
128
|
+
await wait();
|
|
129
|
+
}
|
|
130
|
+
if (error) throw error;
|
|
131
|
+
} finally {
|
|
132
|
+
this.proc.stdout.off('data', onData);
|
|
133
|
+
this.proc.off('exit', onExit);
|
|
134
|
+
this.busy = false;
|
|
135
|
+
this._touch();
|
|
136
|
+
// Abort mid-turn leaves a half-generated reply in the pipe; drop the warm
|
|
137
|
+
// session so the next turn starts clean rather than reading stale output.
|
|
138
|
+
if (aborted) this.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
close() {
|
|
143
|
+
this.alive = false;
|
|
144
|
+
clearTimeout(this._idle);
|
|
145
|
+
if (SESSIONS.get(this.key) === this) SESSIONS.delete(this.key);
|
|
146
|
+
try { if (this.proc && !this.proc.killed) this.proc.kill('SIGTERM'); } catch { /* already gone */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Return the live session for `key`, spawning one if none is alive. A warm
|
|
151
|
+
// session fixes its system prompt at spawn (--append-system-prompt), so if the
|
|
152
|
+
// caller's system changes mid-conversation (e.g. plan-mode toggled), the warm
|
|
153
|
+
// session is stale — evict and respawn rather than answer with the old system.
|
|
154
|
+
export function getSession(key, opts = {}) {
|
|
155
|
+
const existing = SESSIONS.get(key);
|
|
156
|
+
if (existing && existing.alive) {
|
|
157
|
+
if ((existing.opts.system || '') === (opts.system || '')) return existing;
|
|
158
|
+
existing.close();
|
|
159
|
+
}
|
|
160
|
+
const s = new ClaudeSession(key, opts);
|
|
161
|
+
SESSIONS.set(key, s);
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function closeAllSessions() { for (const s of [...SESSIONS.values()]) s.close(); }
|
|
166
|
+
export function _resetSessions() { closeAllSessions(); SESSIONS.clear(); }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Classify a non-zero CLI-provider exit by its stderr.
|
|
2
|
+
//
|
|
3
|
+
// claude-cli / codex-cli / gemini-cli all surface upstream throttling by
|
|
4
|
+
// exiting non-zero with the throttle text on stderr — e.g. claude's
|
|
5
|
+
// "Server temporarily limiting requests (not your usage limit)", an
|
|
6
|
+
// overloaded_error, or a bare HTTP 429/5xx. Those are transient: the same
|
|
7
|
+
// call usually succeeds shortly, so they should map to a RETRIABLE error code
|
|
8
|
+
// (RATE_LIMIT) that withRateLimitRetry retries before the first chunk.
|
|
9
|
+
//
|
|
10
|
+
// A genuine, durable cap — a subscription usage limit, out of credits, quota
|
|
11
|
+
// exceeded — must NOT be retried: retrying just burns the same wall and fails
|
|
12
|
+
// again, so it keeps the non-retriable CLI_EXIT code and surfaces fast.
|
|
13
|
+
//
|
|
14
|
+
// Subtlety: the transient throttle text literally contains the words "usage
|
|
15
|
+
// limit" ("...not your usage limit..."), so the durable-cap pattern is phrased
|
|
16
|
+
// to require the "reached"/"exceeded"/"out of" wording a real cap uses — it
|
|
17
|
+
// must not fire on the transient throttle.
|
|
18
|
+
|
|
19
|
+
// Durable caps — never retry. Each alternative carries the cap-specific
|
|
20
|
+
// wording so the bare phrase "usage limit" inside the transient text can't
|
|
21
|
+
// match.
|
|
22
|
+
const DURABLE_CAP = /usage limit reached|reached your (?:usage|plan|monthly|account|daily) limit|out of credits|quota (?:has been )?exceeded|insufficient (?:\w+ )?credits?|please upgrade|upgrade your plan/i;
|
|
23
|
+
|
|
24
|
+
// Transient throttle / overload — retry after a short pause.
|
|
25
|
+
const TRANSIENT = /temporarily limiting|limiting requests|overloaded|rate.?limited?|too many requests|\b(?:429|500|502|503|504|529)\b|please try again|service unavailable/i;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CLI_RETRY_AFTER_MS = 3000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {unknown} stderr Captured stderr from the failed CLI invocation.
|
|
31
|
+
* @returns {{ code: 'RATE_LIMIT' | 'CLI_EXIT', retriable: boolean, retryAfterMs?: number }}
|
|
32
|
+
*/
|
|
33
|
+
export function classifyCliExit(stderr) {
|
|
34
|
+
const s = String(stderr ?? '');
|
|
35
|
+
if (DURABLE_CAP.test(s)) return { code: 'CLI_EXIT', retriable: false };
|
|
36
|
+
if (TRANSIENT.test(s)) return { code: 'RATE_LIMIT', retriable: true, retryAfterMs: DEFAULT_CLI_RETRY_AFTER_MS };
|
|
37
|
+
return { code: 'CLI_EXIT', retriable: false };
|
|
38
|
+
}
|