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
@@ -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
  }
@@ -88,7 +88,7 @@ export async function callOnce({
88
88
  if (!apiKey) {
89
89
  throw new GeminiToolUseError('apiKey is required', 'NO_API_KEY');
90
90
  }
91
- const m = model || 'gemini-2.5-flash';
91
+ const m = model || 'gemini-2.5-pro';
92
92
  const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/models/${encodeURIComponent(m)}:generateContent`;
93
93
  const fetchFn = fetchImpl || globalThis.fetch;
94
94
 
@@ -137,15 +137,31 @@ export function parseResponse(json) {
137
137
  }
138
138
  }
139
139
  const text = textParts.join('');
140
+ // finishReason MAX_TOKENS = the turn was cut at the output ceiling; the text
141
+ // or functionCall args are partial. Flag it so the runner stops.
142
+ const fr = candidate?.finishReason;
143
+ const truncated = fr === 'MAX_TOKENS' || fr === 'SAFETY' || fr === 'RECITATION';
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 um = json?.usageMetadata;
147
+ const usage = um
148
+ ? {
149
+ inputTokens: um.promptTokenCount || 0,
150
+ outputTokens: um.candidatesTokenCount || 0,
151
+ cacheReadInputTokens: um.cachedContentTokenCount || 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
157
  // assistantContent is the entire model turn so the runner can echo it
144
158
  // back into `contents` for the next request.
145
159
  return {
146
160
  kind: 'tool_calls',
147
161
  text,
162
+ truncated,
148
163
  calls,
164
+ usage,
149
165
  assistantContent: candidate?.content || { role: 'model', parts },
150
166
  raw: json,
151
167
  };
@@ -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,34 @@ 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;
111
134
  }
112
- return { id: tc.id, name: tc?.function?.name, input };
135
+ return { id: tc.id, name: tc?.function?.name, input, ...(parseError ? { parseError } : {}) };
113
136
  });
114
- return { kind: 'tool_calls', text, calls, assistantContent: msg, raw: json };
137
+ return { kind: 'tool_calls', text, calls, truncated, usage, assistantContent: msg, raw: json };
115
138
  }
116
139
 
117
140
  // 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
  }
package/sandbox/index.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  // bindings: { '<workerName>': '<kind>' | { kind, ...overrides } },
17
17
  // }
18
18
 
19
+ import os from 'node:os';
20
+ import path from 'node:path';
19
21
  import { SANDBOX_KINDS, SandboxError } from './base.mjs';
20
22
  import { LocalSandbox } from './local.mjs';
21
23
  import { DockerSandbox, parseSandboxSpec } from './docker.mjs';
@@ -65,3 +67,50 @@ export function resolveSandbox(cfg, workerName) {
65
67
  };
66
68
  return new CTORS[kind](spec);
67
69
  }
70
+
71
+ // Directories whose reads are blocked inside a confined child even though reads
72
+ // are otherwise allowed — credential stores + the lazyclaw config dir (which
73
+ // holds auth profiles and channel tokens).
74
+ function defaultDenyRead(homeDir, configDir) {
75
+ const h = homeDir || os.homedir();
76
+ const dirs = [
77
+ path.join(h, '.ssh'),
78
+ path.join(h, '.aws'),
79
+ path.join(h, '.gnupg'),
80
+ path.join(h, '.config', 'gcloud'),
81
+ path.join(h, '.docker'),
82
+ path.join(h, '.kube'),
83
+ path.join(h, '.npmrc'),
84
+ path.join(h, '.netrc'),
85
+ ];
86
+ if (configDir) dirs.push(configDir);
87
+ return dirs;
88
+ }
89
+
90
+ // Build the flat sandbox spec applied BY DEFAULT to sensitive tool execution
91
+ // (the tool hot path: bash, python_exec/node_exec, git_*, os_*). Returns null to
92
+ // mean "no confinement" (bare host).
93
+ //
94
+ // Default-on policy (operator-chosen): confine every sensitive child-spawning
95
+ // tool, with the filesystem confined to the workspace (cwd) + temp, secret dirs
96
+ // unreadable, and network ALLOWED. Opt out with cfg.sandbox.confine === false or
97
+ // cfg.sandbox.default of 'off'/'none'. An explicitly-configured docker backend
98
+ // is honoured instead of local confinement.
99
+ //
100
+ // This is threaded in by the production entrypoints (task tick, agentic chat,
101
+ // task_spawn); the library defaults of runTool/runAgentTurn stay null so direct
102
+ // API callers and unit tests remain byte-stable.
103
+ export function defaultSandboxSpec(cfg, { cwd, configDir } = {}) {
104
+ const sb = (cfg && cfg.sandbox) || {};
105
+ if (sb.confine === false || sb.default === 'off' || sb.default === 'none') return null;
106
+ if (sb.default === 'docker' && sb.docker && sb.docker.image) {
107
+ return { kind: 'docker', ...sb.docker };
108
+ }
109
+ return {
110
+ kind: 'local',
111
+ confiner: sb.local?.confiner || 'auto',
112
+ readWrite: [cwd || process.cwd()],
113
+ denyRead: defaultDenyRead(sb.homeDir, configDir),
114
+ allowNet: sb.allowNet !== false,
115
+ };
116
+ }
package/sandbox/local.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { Sandbox, SandboxSession, SandboxError } from './base.mjs';
6
+ import { pickAvailableConfiner } from './spawn.mjs';
6
7
  import * as seatbelt from './confiners/seatbelt.mjs';
7
8
  import * as bubblewrap from './confiners/bubblewrap.mjs';
8
9
  import * as firejail from './confiners/firejail.mjs';
@@ -45,7 +46,12 @@ class LocalSession extends SandboxSession {
45
46
  export class LocalSandbox extends Sandbox {
46
47
  constructor(spec) {
47
48
  super(spec);
48
- const key = spec.confiner || 'none';
49
+ // Resolve 'auto' to a concrete confiner (or none) at construction so
50
+ // _wrap() never sees 'auto' and the genuinely-unknown-key guard below
51
+ // still fires for typos. 'auto' that resolves to 'none' → no wrapping.
52
+ const key = spec.confiner === 'auto'
53
+ ? pickAvailableConfiner()
54
+ : (spec.confiner || 'none');
49
55
  if (key !== 'none' && !(key in CONFINERS)) {
50
56
  throw new SandboxError(`unknown confiner "${key}"`, 'SANDBOX_BAD_CONFINER');
51
57
  }