lazyclaw 6.3.1 → 6.5.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 (179) 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 +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
@@ -27,7 +27,8 @@
27
27
  // "gemini:gemini-2.5-pro"
28
28
  // ],
29
29
  // "maxSubtasks": 5, // optional, default 5
30
- // "concurrency": 0 // optional, 0 = sequential (visible streaming)
30
+ // "concurrency": 0 // optional; default = min(3, workers), parallel.
31
+ // // 0 or 1 = sequential (visible live streaming)
31
32
  // }
32
33
  // }
33
34
  //
@@ -86,6 +87,24 @@ function _bestPlanArray(text) {
86
87
  return null;
87
88
  }
88
89
 
90
+ // Default worker-pool concurrency when cfg.orchestrator.concurrency is unset.
91
+ // Parallel is the point of a worker pool; an unconfigured fleet should not run
92
+ // subtasks one at a time. Clamped to the worker count at the call site, and an
93
+ // explicit 0/1 still selects the sequential live-streaming path.
94
+ const DEFAULT_CONCURRENCY = 3;
95
+
96
+ // Opt-in agentic workers (cfg.orchestrator.agenticWorkers): an EXECUTE worker
97
+ // runs through runAgentTurn so it can actually DO work with its tools (shell,
98
+ // file read, recall) instead of only streaming text. Default OFF — the
99
+ // text-streaming path is byte-stable. Tool calls are confined by the sandbox
100
+ // the caller passes; the loop is bounded by workerMaxIterations.
101
+ const DEFAULT_WORKER_TOOLS = ['bash', 'read', 'grep', 'recall'];
102
+ const DEFAULT_WORKER_MAX_ITERATIONS = 8;
103
+ const AGENTIC_WORKER_ROLE =
104
+ 'You are an orchestrator worker. Complete ONLY the assigned subtask. Use your ' +
105
+ 'tools (shell, file read, grep, recall) to do real work when useful, then report ' +
106
+ 'the result concisely. Do not ask questions — act, then summarise what you found.';
107
+
89
108
  const PLANNER_SYSTEM = `You are an orchestrator that decomposes a user request into independent subtasks for parallel worker agents.
90
109
 
91
110
  Rules:
@@ -262,9 +281,47 @@ export function makeOrchestratorProvider(opts = {}) {
262
281
  // regardless of which worker finished first.
263
282
  // The number is clamped to [1, workers.length] so a runaway value
264
283
  // can't accidentally over-subscribe a single worker.
265
- const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : 1;
284
+ const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : DEFAULT_CONCURRENCY;
266
285
  const concurrency = Math.max(1, Math.min(rawConcurrency, workers.length));
267
286
  yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}${concurrency > 1 ? ` (concurrency=${concurrency}, parallel)` : ''}\n\n`;
287
+
288
+ // Run one worker on one subtask through the agentic tool loop. Confined by
289
+ // the caller's sandbox; bounded by workerMaxIterations; abort-propagating.
290
+ const _runAgenticWorker = async (worker, sub) => {
291
+ const { runAgentTurn } = await import('../mas/agent_turn.mjs');
292
+ const r = await runAgentTurn({
293
+ agent: {
294
+ name: worker.name, provider: worker.name, model: worker.model,
295
+ role: AGENTIC_WORKER_ROLE,
296
+ tools: Array.isArray(o.workerTools) ? o.workerTools : DEFAULT_WORKER_TOOLS,
297
+ },
298
+ userMessage: sub.task,
299
+ configDir: callerOpts.configDir,
300
+ cwd: callerOpts.cwd,
301
+ apiKey: keyResolver(cfg, worker.name),
302
+ fetchImpl: callerOpts.fetchImpl,
303
+ sandbox: callerOpts.sandbox,
304
+ signal: callerOpts.signal,
305
+ maxIterations: Number.isFinite(o.workerMaxIterations) ? o.workerMaxIterations : DEFAULT_WORKER_MAX_ITERATIONS,
306
+ });
307
+ return r.text || '';
308
+ };
309
+ // Worker output as a chunk stream. agenticWorkers OFF (default) → the
310
+ // provider's live token stream, byte-stable. ON → one buffered chunk with
311
+ // the agent turn's final answer (tool work happened inside).
312
+ const _workerChunks = async function* (worker, sub) {
313
+ if (o.agenticWorkers) {
314
+ const text = await _runAgenticWorker(worker, sub);
315
+ if (text) yield text;
316
+ return;
317
+ }
318
+ yield* worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
319
+ apiKey: keyResolver(cfg, worker.name),
320
+ model: worker.model || undefined,
321
+ signal: callerOpts.signal,
322
+ });
323
+ };
324
+
268
325
  const results = [];
269
326
  if (concurrency <= 1) {
270
327
  // Sequential streaming path — historical default, preserved.
@@ -274,11 +331,7 @@ export function makeOrchestratorProvider(opts = {}) {
274
331
  yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
275
332
  let res = '';
276
333
  try {
277
- for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
278
- apiKey: keyResolver(cfg, worker.name),
279
- model: worker.model || undefined,
280
- signal: callerOpts.signal,
281
- })) {
334
+ for await (const chunk of _workerChunks(worker, sub)) {
282
335
  const s = String(chunk);
283
336
  res += s;
284
337
  yield s;
@@ -301,11 +354,7 @@ export function makeOrchestratorProvider(opts = {}) {
301
354
  const chunks = [];
302
355
  let error = null;
303
356
  try {
304
- for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
305
- apiKey: keyResolver(cfg, worker.name),
306
- model: worker.model || undefined,
307
- signal: callerOpts.signal,
308
- })) {
357
+ for await (const chunk of _workerChunks(worker, sub)) {
309
358
  chunks.push(String(chunk));
310
359
  }
311
360
  } catch (e) {
@@ -393,3 +442,54 @@ export function makeOrchestratorProvider(opts = {}) {
393
442
  },
394
443
  };
395
444
  }
445
+
446
+ // Minimal one-shot worker dispatch for the `delegate` agent tool.
447
+ //
448
+ // `delegate` hands a single subtask to ONE provider — no plan/synthesis.
449
+ // It reuses the same spec-resolution + key-resolution infra the
450
+ // orchestrator's EXECUTE phase uses (a "<provider>[:<model>]" spec routed
451
+ // through PROVIDERS/PROVIDER_INFO, key via lib/config::_resolveAuthKey),
452
+ // but as a standalone call so the `delegate` tool actually runs.
453
+ //
454
+ // Imports are lazy so this module keeps its leaf static-dep graph (it must
455
+ // NOT statically import ./registry.mjs — that re-forms the registry↔orch
456
+ // cycle; see the header note above).
457
+ //
458
+ // job = { worker: '<provider>[:<model>]', prompt, model? }
459
+ // returns { ok:true, text } | { ok:false, error }
460
+ export async function dispatchWorker(job = {}) {
461
+ const workerSpec = String(job?.worker || '');
462
+ const prompt = String(job?.prompt || '');
463
+ if (!workerSpec || !prompt) return { ok: false, error: 'delegate: worker + prompt required' };
464
+
465
+ let registry, config;
466
+ try {
467
+ registry = await import('./registry.mjs');
468
+ config = await import('../lib/config.mjs');
469
+ } catch (e) {
470
+ return { ok: false, error: `delegate: failed to load provider infra: ${e?.message || String(e)}` };
471
+ }
472
+
473
+ const lookup = (p) => ({ prov: registry.PROVIDERS[p], info: registry.PROVIDER_INFO[p] });
474
+ const worker = _lookupProvider(workerSpec, lookup);
475
+ if (!worker) return { ok: false, error: `delegate: unknown worker provider "${_parseSpec(workerSpec).provider}"` };
476
+ if (worker.name === 'orchestrator') return { ok: false, error: 'delegate: worker cannot be "orchestrator"' };
477
+ // Explicit job.model overrides the model parsed from the spec.
478
+ const model = String(job?.model || '') || worker.model || '';
479
+
480
+ const cfg = config.readConfig() || {};
481
+ const apiKey = config._resolveAuthKey(cfg, worker.name);
482
+
483
+ let text = '';
484
+ try {
485
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: prompt }], {
486
+ apiKey,
487
+ model: model || undefined,
488
+ })) {
489
+ text += String(chunk);
490
+ }
491
+ } catch (e) {
492
+ return { ok: false, error: `delegate: ${worker.name} error: ${e?.message || String(e)}` };
493
+ }
494
+ return { ok: true, text };
495
+ }
@@ -191,9 +191,8 @@ export const PROVIDER_INFO = {
191
191
  requiresApiKey: false,
192
192
  docs: 'Anthropic via the local `claude` CLI (Pro / Max subscription). No API key — auth flows through whatever account `claude` is logged in with. Requires Claude Code installed.',
193
193
  endpoint: 'subprocess: claude -p',
194
- defaultModel: 'claude-opus-4-7',
194
+ defaultModel: 'claude-opus-4-8',
195
195
  suggestedModels: [
196
- 'claude-fable-5',
197
196
  'claude-opus-4-8',
198
197
  'claude-opus-4-7',
199
198
  'claude-opus-4-6',
@@ -207,18 +206,26 @@ export const PROVIDER_INFO = {
207
206
  'gemini-cli': {
208
207
  name: 'gemini-cli',
209
208
  requiresApiKey: false,
210
- docs: 'Google Gemini via the local `gemini` CLI (free Google-account login). No API key — auth flows through whatever account `gemini` is logged in with. Requires @google/gemini-cli installed.',
209
+ docs: 'Google Gemini via the local `gemini` CLI (free Google-account login). No API key — auth flows through whatever account `gemini` is logged in with. Requires @google/gemini-cli installed. defaultModel is null on purpose: the set of models a given Google login may use is decided server-side, so by default lazyclaw passes no `-m` and lets the CLI pick the account-appropriate model. The suggestedModels below are optional power-user overrides.',
211
210
  endpoint: 'subprocess: gemini -p',
212
- defaultModel: 'gemini-2.5-pro',
211
+ defaultModel: null,
213
212
  suggestedModels: ['gemini-2.5-pro', 'gemini-2.5-flash', 'pro', 'flash'],
214
213
  },
215
214
  'codex-cli': {
216
215
  name: 'codex-cli',
217
216
  requiresApiKey: false,
218
- docs: 'OpenAI via the local `codex` CLI (ChatGPT Plus/Pro subscription). No API key — auth flows through `codex` login. Requires the codex CLI installed.',
217
+ // defaultModel is null by design. A ChatGPT-account codex login only
218
+ // accepts the models that plan is entitled to (read from ~/.codex/
219
+ // config.toml, e.g. "gpt-5.5"); forcing a marketing id like the old
220
+ // default "gpt-5-codex" makes the API reject the turn with HTTP 400
221
+ // "The '<model>' model is not supported when using Codex with a ChatGPT
222
+ // account." So by default we pass NO `-m` and let codex use its own
223
+ // account default. suggestedModels is a single best-effort hint; the
224
+ // reliable path is the picker's "use the provider's own default" entry.
225
+ docs: 'OpenAI via the local `codex` CLI (ChatGPT Plus/Pro subscription). No API key — auth flows through `codex` login. Requires the codex CLI installed. By default no model is forced: codex uses the account default from ~/.codex/config.toml (a ChatGPT-plan login rejects models it is not entitled to). Override only with a model your plan allows.',
219
226
  endpoint: 'subprocess: codex exec',
220
- defaultModel: 'gpt-5-codex',
221
- suggestedModels: ['gpt-5-codex', 'gpt-5', 'o3'],
227
+ defaultModel: null,
228
+ suggestedModels: ['gpt-5.5'],
222
229
  },
223
230
  anthropic: {
224
231
  name: 'anthropic',
@@ -226,9 +233,8 @@ export const PROVIDER_INFO = {
226
233
  keyPrefix: 'sk-ant-',
227
234
  docs: 'Anthropic Messages API (pay-per-token, requires sk-ant- key). Supports streaming + extended thinking. For subscription billing, use the `claude-cli` provider instead.',
228
235
  endpoint: 'https://api.anthropic.com/v1/messages',
229
- defaultModel: 'claude-opus-4-7',
236
+ defaultModel: 'claude-opus-4-8',
230
237
  suggestedModels: [
231
- 'claude-fable-5',
232
238
  'claude-opus-4-8',
233
239
  'claude-opus-4-7',
234
240
  'claude-opus-4-6',
@@ -24,6 +24,17 @@ const DEFAULT_ATTEMPTS = 3;
24
24
  const DEFAULT_MAX_BACKOFF_MS = 60_000;
25
25
  const ABSOLUTE_MAX_BACKOFF_MS = 5 * 60_000; // hard ceiling, ignores caller
26
26
 
27
+ // Errors safe to retry before the first chunk: rate limits AND transient
28
+ // server-overload / 5xx. Anthropic surfaces overload as HTTP 529
29
+ // (overloaded_error); generic upstreams use 5xx. 4xx other than 429 are
30
+ // caller-fault and never retried.
31
+ function isRetriableError(err) {
32
+ const code = err?.code;
33
+ if (code === 'RATE_LIMIT' || code === 'OVERLOADED' || code === 'SERVER_ERROR') return true;
34
+ const status = err?.status;
35
+ return Number.isFinite(status) && status >= 500 && status < 600;
36
+ }
37
+
27
38
  function clampBackoff(retryAfterMs, max) {
28
39
  const ceiling = Math.min(max, ABSOLUTE_MAX_BACKOFF_MS);
29
40
  if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) return ceiling;
@@ -85,8 +96,9 @@ export function withRateLimitRetry(provider, retryOpts = {}) {
85
96
  lastErr = err;
86
97
  // Mid-stream errors cannot be retried: we'd produce duplicate text.
87
98
  if (yieldedAny) throw err;
88
- // Only retry RATE_LIMIT and only if we still have attempts left.
89
- if (err?.code !== 'RATE_LIMIT' || attempt >= attempts) throw err;
99
+ // Retry rate-limit AND transient server-overload/5xx, only while
100
+ // attempts remain. 4xx other than 429 bubble unchanged.
101
+ if (!isRetriableError(err) || attempt >= attempts) throw err;
90
102
  const wait = clampBackoff(err.retryAfterMs, maxBackoffMs);
91
103
  if (typeof onRetry === 'function') {
92
104
  try { onRetry({ attempt: attempt + 1, retryAfterMs: wait, err }); } catch { /* swallow */ }
@@ -68,7 +68,7 @@ export async function callOnce({
68
68
  const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/messages`;
69
69
  const fetchFn = fetchImpl || globalThis.fetch;
70
70
  const body = {
71
- model: model || 'claude-opus-4-7',
71
+ model: model || 'claude-opus-4-8',
72
72
  max_tokens: maxTokens,
73
73
  messages,
74
74
  };
@@ -137,10 +137,24 @@ export function parseResponse(json) {
137
137
  }
138
138
  }
139
139
  const text = textParts.join('');
140
+ // The model hit the output token ceiling mid-turn: the text or tool_use
141
+ // block is partial/garbage. Flag it so the runner stops instead of acting on
142
+ // a cut-off final answer or an incomplete tool call.
143
+ const truncated = json?.stop_reason === 'max_tokens';
144
+ // Normalize token usage so agent_turn can accumulate spend across the loop
145
+ // (and team turns can feed the cost cap). null when the response omits it.
146
+ const usage = json?.usage
147
+ ? {
148
+ inputTokens: json.usage.input_tokens || 0,
149
+ outputTokens: json.usage.output_tokens || 0,
150
+ cacheCreationInputTokens: json.usage.cache_creation_input_tokens || 0,
151
+ cacheReadInputTokens: json.usage.cache_read_input_tokens || 0,
152
+ }
153
+ : null;
140
154
  if (calls.length === 0) {
141
- return { kind: 'final', text, raw: json };
155
+ return { kind: 'final', text, truncated, usage, raw: json };
142
156
  }
143
- return { kind: 'tool_calls', text, calls, assistantContent: content, raw: json };
157
+ return { kind: 'tool_calls', text, calls, truncated, usage, assistantContent: content, raw: json };
144
158
  }
145
159
 
146
160
  // Anthropic accepts the agent_turn-native {role, content} shape
@@ -25,6 +25,7 @@
25
25
  // point at a deterministic shim script.
26
26
 
27
27
  import { spawn } from 'node:child_process';
28
+ import { classifyCliExit } from '../cli_error.mjs';
28
29
 
29
30
  const DEFAULT_BIN = 'claude';
30
31
  const LAZYCLAW_TO_CLAUDE_TOOL = {
@@ -107,6 +108,11 @@ async function readUntilDone(proc) {
107
108
  let acc = '';
108
109
  let assistantFallback = '';
109
110
  let resultText = '';
111
+ // Per-turn usage rides the `assistant` event (the streaming `result` event
112
+ // reports zero tokens); cost rides the result event. Accumulate so the cost
113
+ // cap can account for this default subscription path.
114
+ const u = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, cost: 0, saw: false };
115
+ let truncated = false; // a --max-turns cap hit (result subtype error_max_turns)
110
116
  proc.stdout.setEncoding('utf8');
111
117
  proc.stderr.setEncoding('utf8');
112
118
  proc.stdout.on('data', (chunk) => {
@@ -131,22 +137,80 @@ async function readUntilDone(proc) {
131
137
  if (obj?.type === 'result' && typeof obj.result === 'string') {
132
138
  resultText = obj.result;
133
139
  }
140
+ if (obj?.type === 'assistant' && obj?.message?.usage) {
141
+ const mu = obj.message.usage;
142
+ u.input += mu.input_tokens || 0;
143
+ u.output += mu.output_tokens || 0;
144
+ u.cacheCreate += mu.cache_creation_input_tokens || 0;
145
+ u.cacheRead += mu.cache_read_input_tokens || 0;
146
+ u.saw = true;
147
+ }
148
+ if (obj?.type === 'result' && Number.isFinite(obj.total_cost_usd)) u.cost = obj.total_cost_usd;
149
+ if (obj?.type === 'result' && obj.is_error && /max_turns/.test(String(obj.subtype || ''))) truncated = true;
134
150
  }
135
151
  });
136
152
  proc.stderr.on('data', (chunk) => { stderr += chunk; });
137
153
  proc.on('error', reject);
154
+ const buildUsage = () => ((u.saw || u.cost)
155
+ ? {
156
+ inputTokens: u.input, outputTokens: u.output,
157
+ cacheCreationInputTokens: u.cacheCreate, cacheReadInputTokens: u.cacheRead,
158
+ totalCostUsd: u.cost,
159
+ }
160
+ : null);
138
161
  proc.on('close', (code) => {
139
- if (code !== 0) {
140
- return reject(new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, 'CLAUDE_CLI_EXIT', stderr));
162
+ // A --max-turns cap hit exits non-zero but is NOT a failure to surface as
163
+ // an error — return it as truncated so agent_turn stops cleanly with
164
+ // stoppedBy:'truncated' ("raise maxTokens") rather than a CLI exit error.
165
+ if (code !== 0 && !truncated) {
166
+ // Transient upstream throttle → retriable RATE_LIMIT; otherwise keep
167
+ // the non-retriable CLAUDE_CLI_EXIT code.
168
+ const cls = classifyCliExit(stderr);
169
+ const exitCode = cls.code === 'RATE_LIMIT' ? 'RATE_LIMIT' : 'CLAUDE_CLI_EXIT';
170
+ const err = new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, exitCode, stderr);
171
+ if (cls.retryAfterMs !== undefined) err.retryAfterMs = cls.retryAfterMs;
172
+ return reject(err);
141
173
  }
142
174
  // Prefer accumulated stream deltas; fall back to the assistant
143
175
  // record or the final result text when streaming was disabled.
144
176
  const text = acc || assistantFallback || resultText || '';
145
- resolve(text);
177
+ resolve({ text, usage: buildUsage(), truncated });
146
178
  });
147
179
  });
148
180
  }
149
181
 
182
+ // Build the `claude` argv for the tool-use path. Runs LEAN by default —
183
+ // single-sourced with providers/claude_cli.mjs's policy — so this path (agentic
184
+ // chat / every mention-router team turn / the per-turn trainer calls) does NOT
185
+ // re-load the user's CLAUDE.md/skills/hooks/MCP (~180k tokens/spawn, measured).
186
+ // It previously omitted the lean flags, which is why the streaming provider's
187
+ // lean fix didn't help these paths. Pass lean:false to restore the full env.
188
+ export function buildToolUseArgs({ prompt, model, system, tools = [], permissionMode = 'bypassPermissions', lean, maxTurns } = {}) {
189
+ const args = [
190
+ '-p', prompt,
191
+ '--output-format', 'stream-json',
192
+ '--include-partial-messages',
193
+ '--verbose',
194
+ '--permission-mode', permissionMode,
195
+ ];
196
+ if (lean !== false) {
197
+ args.push('--setting-sources', '', '--strict-mcp-config');
198
+ }
199
+ // Bound Claude Code's internal autonomous loop. This adapter DOES let the
200
+ // agent use tools (--tools whitelist below), but the internal loop was
201
+ // otherwise uncapped (lazyclaw's own maxIterations is a no-op for claude-cli).
202
+ const cap = maxTurns == null ? 16 : maxTurns;
203
+ if (cap > 0) args.push('--max-turns', String(cap));
204
+ if (model) args.push('--model', model);
205
+ if (system && String(system).trim()) {
206
+ args.push('--system-prompt', String(system));
207
+ }
208
+ // Phase 19: pass the lazyclaw whitelist through to claude's --tools even when
209
+ // empty (an empty string explicitly disables every tool).
210
+ args.push('--tools', toClaudeTools(tools));
211
+ return args;
212
+ }
213
+
150
214
  export async function callOnce({
151
215
  messages,
152
216
  tools = [],
@@ -158,6 +222,8 @@ export async function callOnce({
158
222
  signal,
159
223
  bin,
160
224
  cwd,
225
+ lean,
226
+ maxTurns,
161
227
  permissionMode = 'bypassPermissions',
162
228
  } = {}) {
163
229
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -167,21 +233,7 @@ export async function callOnce({
167
233
  if (!prompt) {
168
234
  throw new ClaudeCliToolUseError('messages produced an empty prompt', 'NO_PROMPT');
169
235
  }
170
- const args = [
171
- '-p', prompt,
172
- '--output-format', 'stream-json',
173
- '--include-partial-messages',
174
- '--verbose',
175
- '--permission-mode', permissionMode,
176
- ];
177
- if (model) args.push('--model', model);
178
- if (system && String(system).trim()) {
179
- args.push('--system-prompt', String(system));
180
- }
181
- // Phase 19: pass the lazyclaw whitelist through to claude's --tools
182
- // even when empty (an empty string explicitly disables every tool).
183
- const toolsArg = toClaudeTools(tools);
184
- args.push('--tools', toolsArg);
236
+ const args = buildToolUseArgs({ prompt, model, system, tools, permissionMode, lean, maxTurns });
185
237
 
186
238
  const binPath = bin || process.env.LAZYCLAW_CLAUDE_BIN || DEFAULT_BIN;
187
239
  let proc;
@@ -200,8 +252,8 @@ export async function callOnce({
200
252
  const onAbort = () => { try { proc.kill('SIGTERM'); } catch { /* gone */ } };
201
253
  if (signal) signal.addEventListener('abort', onAbort);
202
254
  try {
203
- const text = await readUntilDone(proc);
204
- return { kind: 'final', text, raw: null };
255
+ const { text, usage, truncated } = await readUntilDone(proc);
256
+ return { kind: 'final', text, usage, truncated, raw: null };
205
257
  } finally {
206
258
  if (signal) signal.removeEventListener('abort', onAbort);
207
259
  }
@@ -78,6 +78,7 @@ export async function callOnce({
78
78
  model,
79
79
  apiKey,
80
80
  system,
81
+ maxTokens,
81
82
  baseUrl,
82
83
  fetchImpl,
83
84
  signal,
@@ -88,7 +89,7 @@ export async function callOnce({
88
89
  if (!apiKey) {
89
90
  throw new GeminiToolUseError('apiKey is required', 'NO_API_KEY');
90
91
  }
91
- const m = model || 'gemini-2.5-flash';
92
+ const m = model || 'gemini-2.5-pro';
92
93
  const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/models/${encodeURIComponent(m)}:generateContent`;
93
94
  const fetchFn = fetchImpl || globalThis.fetch;
94
95
 
@@ -97,6 +98,13 @@ export async function callOnce({
97
98
  if (system && String(system).trim()) {
98
99
  body.system_instruction = { parts: [{ text: String(system) }] };
99
100
  }
101
+ // Cap the output like the anthropic/openai adapters do. Gemini carries the
102
+ // ceiling on generationConfig.maxOutputTokens; merge so a caller that already
103
+ // populated generationConfig keeps their other fields. Only a finite,
104
+ // positive maxTokens applies — omitting it leaves generationConfig untouched.
105
+ if (Number.isFinite(maxTokens) && maxTokens > 0) {
106
+ body.generationConfig = { ...(body.generationConfig || {}), maxOutputTokens: maxTokens };
107
+ }
100
108
 
101
109
  const res = await fetchFn(url, {
102
110
  method: 'POST',
@@ -137,15 +145,34 @@ export function parseResponse(json) {
137
145
  }
138
146
  }
139
147
  const text = textParts.join('');
148
+ // finishReason MAX_TOKENS = the turn was cut at the output ceiling; the text
149
+ // or functionCall args are partial. Flag it so the runner stops.
150
+ const fr = candidate?.finishReason;
151
+ const truncated = fr === 'MAX_TOKENS' || fr === 'SAFETY' || fr === 'RECITATION';
152
+ // Normalize token usage so agent_turn can accumulate spend across the loop
153
+ // (and team turns can feed the cost cap). null when the response omits it.
154
+ const um = json?.usageMetadata;
155
+ const usage = um
156
+ ? {
157
+ // promptTokenCount is cache-INCLUSIVE; report NET (minus cached) to
158
+ // match Anthropic's convention so the cost cap doesn't bill the cached
159
+ // tokens at BOTH the input rate and the cache-read rate.
160
+ inputTokens: (um.promptTokenCount || 0) - (um.cachedContentTokenCount || 0),
161
+ outputTokens: um.candidatesTokenCount || 0,
162
+ cacheReadInputTokens: um.cachedContentTokenCount || 0,
163
+ }
164
+ : null;
140
165
  if (calls.length === 0) {
141
- return { kind: 'final', text, raw: json };
166
+ return { kind: 'final', text, truncated, usage, raw: json };
142
167
  }
143
168
  // assistantContent is the entire model turn so the runner can echo it
144
169
  // back into `contents` for the next request.
145
170
  return {
146
171
  kind: 'tool_calls',
147
172
  text,
173
+ truncated,
148
174
  calls,
175
+ usage,
149
176
  assistantContent: candidate?.content || { role: 'model', parts },
150
177
  raw: json,
151
178
  };
@@ -18,6 +18,12 @@
18
18
  const DEFAULT_BASE = 'https://api.openai.com/v1';
19
19
  const DEFAULT_MAX_TOKENS = 4096;
20
20
 
21
+ // OpenAI o-series reasoning models (o1/o3/o4...) reject `max_tokens` with
22
+ // HTTP 400 'Unsupported parameter: max_tokens, use max_completion_tokens'.
23
+ function isReasoningModel(model) {
24
+ return /^o\d/i.test(String(model || ''));
25
+ }
26
+
21
27
  export class OpenAIToolUseError extends Error {
22
28
  constructor(message, code, body) {
23
29
  super(message);
@@ -71,8 +77,11 @@ export async function callOnce({
71
77
  const body = {
72
78
  model: model || 'gpt-4.1',
73
79
  messages: fullMessages,
74
- max_tokens: maxTokens,
75
80
  };
81
+ // Reasoning models take max_completion_tokens; everything else takes
82
+ // max_tokens. Never emit both — the o-series rejects max_tokens.
83
+ if (isReasoningModel(body.model)) body.max_completion_tokens = maxTokens;
84
+ else body.max_tokens = maxTokens;
76
85
  if (tools && tools.length) body.tools = tools;
77
86
 
78
87
  const res = await fetchFn(url, {
@@ -98,20 +107,41 @@ export function parseResponse(json) {
98
107
  const msg = choice?.message || {};
99
108
  const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
100
109
  const text = typeof msg.content === 'string' ? msg.content : '';
110
+ // finish_reason 'length' = the response was cut at the token ceiling, so the
111
+ // content or a tool_call's arguments JSON is partial. Flag it so the runner
112
+ // stops instead of acting on truncated output / empty-parsed args.
113
+ const truncated = choice?.finish_reason === 'length';
114
+ // Normalize token usage so agent_turn can accumulate spend across the loop
115
+ // (and team turns can feed the cost cap). null when the response omits it.
116
+ const usage = json?.usage
117
+ ? { inputTokens: json.usage.prompt_tokens || 0, outputTokens: json.usage.completion_tokens || 0 }
118
+ : null;
101
119
  if (rawToolCalls.length === 0) {
102
- return { kind: 'final', text, raw: json };
120
+ return { kind: 'final', text, truncated, usage, raw: json };
103
121
  }
104
122
  const calls = rawToolCalls.map((tc) => {
105
123
  let input = {};
124
+ let parseError = null;
106
125
  const a = tc?.function?.arguments;
107
126
  if (typeof a === 'string') {
108
- try { input = JSON.parse(a); } catch { input = {}; }
127
+ // Empty-string args mean "no arguments" {}. A non-empty string that
128
+ // fails to parse is malformed: record the error so agent_turn surfaces a
129
+ // tool failure instead of silently running the tool with {}.
130
+ if (a.trim() === '') input = {};
131
+ else { try { input = JSON.parse(a); } catch (e) { input = {}; parseError = `malformed tool arguments: ${e.message}`; } }
109
132
  } else if (a && typeof a === 'object') {
110
133
  input = a;
134
+ } else if (a !== undefined && a !== null) {
135
+ // arguments is present but neither a string nor an object (e.g. a number
136
+ // or boolean) — an unexpected wire shape. Surface it as a tool failure
137
+ // instead of silently running the tool with {} (mirrors the malformed
138
+ // JSON-string branch above).
139
+ input = {};
140
+ parseError = `unexpected tool arguments type: ${typeof a}`;
111
141
  }
112
- return { id: tc.id, name: tc?.function?.name, input };
142
+ return { id: tc.id, name: tc?.function?.name, input, ...(parseError ? { parseError } : {}) };
113
143
  });
114
- return { kind: 'tool_calls', text, calls, assistantContent: msg, raw: json };
144
+ return { kind: 'tool_calls', text, calls, truncated, usage, assistantContent: msg, raw: json };
115
145
  }
116
146
 
117
147
  // OpenAI accepts the agent_turn-native {role, content} shape directly.
@@ -3,12 +3,23 @@
3
3
 
4
4
  import { execFileSync } from 'node:child_process';
5
5
 
6
- export function available() {
7
- if (process.platform !== 'darwin') return false;
8
- try { execFileSync('sandbox-exec', ['-h'], { stdio: 'ignore' }); return true; }
6
+ // Probe whether a usable `sandbox-exec` is present. The argument object is a
7
+ // test seam: `platform` overrides the OS gate and `probe` overrides the real
8
+ // invocation so the try/catch semantics are verifiable without a host sandbox.
9
+ export function available({ platform = process.platform, probe = _realProbe } = {}) {
10
+ if (platform !== 'darwin') return false;
11
+ try { probe(); return true; }
9
12
  catch { return false; }
10
13
  }
11
14
 
15
+ // Run a no-op (`/usr/bin/true`) under a permissive profile. This actually
16
+ // exercises the sandboxing path. NOTE: `-h` is NOT a help flag on macOS — it is
17
+ // an illegal option that exits non-zero, so the previous `sandbox-exec -h`
18
+ // probe reported seatbelt unavailable on every mac.
19
+ function _realProbe() {
20
+ execFileSync('sandbox-exec', ['-p', '(version 1)(allow default)', '/usr/bin/true'], { stdio: 'ignore' });
21
+ }
22
+
12
23
  // Escape a path for an SBPL double-quoted string literal. Without this a path
13
24
  // containing `"` (or a backslash) could close the string and inject arbitrary
14
25
  // SBPL directives — e.g. re-enabling `(allow network*)` or widening file
@@ -25,20 +36,34 @@ function sbplPath(p) {
25
36
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
37
  }
27
38
 
39
+ // Temp dirs are always writable — interpreters and tools need scratch space, and
40
+ // confining writes to the workspace alone breaks too much (e.g. node/npm caches).
41
+ const TMP_WRITABLE = ['/tmp', '/private/tmp', '/private/var/folders'];
42
+
43
+ // Build a FILESYSTEM-CONFINEMENT profile, not a strict deny-default one. A
44
+ // `(deny default)` profile silently kills dynamically-linked binaries (python3,
45
+ // node, git) at the dyld/mach bootstrap stage on modern macOS — confinement that
46
+ // breaks every real interpreter is worse than none. Instead we start from
47
+ // `(allow default)` and carve out the high-value protections: writes are denied
48
+ // everywhere except the workspace + temp, and named secret dirs stay unreadable
49
+ // even though reads are otherwise allowed. Network is allowed unless opts.allowNet
50
+ // is explicitly false.
51
+ //
52
+ // opts.readWrite : string[] workspace roots that may be written (default [cwd])
53
+ // opts.denyRead : string[] dirs whose reads are blocked (e.g. ~/.ssh, ~/.aws)
54
+ // opts.allowNet : boolean default false → adds (deny network*)
28
55
  export function buildArgv(argv, opts = {}) {
29
- const readOnly = opts.readOnly || [];
30
56
  const readWrite = opts.readWrite || [process.cwd()];
57
+ const denyRead = opts.denyRead || [];
31
58
  const allowNet = opts.allowNet === true;
59
+ const writable = [...TMP_WRITABLE, ...readWrite];
32
60
  const profile = [
33
61
  '(version 1)',
34
- '(deny default)',
35
- '(allow process-fork)',
36
- '(allow process-exec)',
37
- '(allow signal)',
38
- '(allow sysctl-read)',
39
- allowNet ? '(allow network*)' : '(deny network*)',
40
- ...readOnly.map(p => `(allow file-read* (subpath "${sbplPath(p)}"))`),
41
- ...readWrite.map(p => `(allow file-read* file-write* (subpath "${sbplPath(p)}"))`),
62
+ '(allow default)',
63
+ '(deny file-write*)',
64
+ `(allow file-write* ${writable.map(p => `(subpath "${sbplPath(p)}")`).join(' ')})`,
65
+ ...denyRead.map(p => `(deny file-read* (subpath "${sbplPath(p)}"))`),
66
+ ...(allowNet ? [] : ['(deny network*)']),
42
67
  ].join('\n');
43
68
  return ['sandbox-exec', '-p', profile, ...argv];
44
69
  }