lazyclaw 6.3.0 → 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.
Files changed (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. 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.0",
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",
@@ -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-7';
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: opts.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
- for await (const chunk of iterateBody(res.body)) {
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
- if (usage && typeof opts.onUsage === 'function') {
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;
@@ -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?.system || null,
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
  };
@@ -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
- this.code = 'CLI_EXIT';
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
- if (obj?.type === 'result') {
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
+ }