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.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +95 -37
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +210 -95
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/providers/registry.mjs
CHANGED
|
@@ -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-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
221
|
-
suggestedModels: ['gpt-5
|
|
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-
|
|
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',
|
package/providers/retry.mjs
CHANGED
|
@@ -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
|
-
//
|
|
89
|
-
|
|
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-
|
|
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
|
-
|
|
140
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
'(
|
|
35
|
-
'(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'(
|
|
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
|
-
|
|
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
|
}
|