lazyclaw 6.4.0 → 6.6.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.
@@ -102,11 +102,45 @@ export async function cmdConfigEdit() {
102
102
  }
103
103
  }
104
104
 
105
+ // Coerce a CLI string value into the type it most likely represents so a
106
+ // boolean/number setting isn't stored as a string the rest of the code then
107
+ // mis-compares (`if (cfg.chat.recall)` is truthy for the string "false").
108
+ // 'true' / 'false' → boolean
109
+ // integer / float strings → number
110
+ // everything else → the original string (provider/model/api-key/…)
111
+ function coerceConfigValue(value) {
112
+ if (typeof value !== 'string') return value;
113
+ if (value === 'true') return true;
114
+ if (value === 'false') return false;
115
+ // Only treat as a number when the WHOLE string is a clean numeric literal —
116
+ // Number('') is 0 and Number('1.2.3') is NaN, both of which we reject so
117
+ // ids like "gpt-4.1" or empty values stay strings.
118
+ if (value.trim() !== '' && /^-?(?:\d+|\d*\.\d+|\d+\.\d*)$/.test(value.trim()) && Number.isFinite(Number(value))) {
119
+ return Number(value);
120
+ }
121
+ return value;
122
+ }
123
+
105
124
  export function cmdConfigSet(key, value) {
106
125
  const cfg = readConfig();
107
- cfg[key] = value;
126
+ const coerced = coerceConfigValue(value);
127
+ if (typeof key === 'string' && key.includes('.')) {
128
+ // Dotted key → nested path. Walk/create each intermediate object so
129
+ // `chat.recall` lands as cfg.chat.recall (not a flat "chat.recall" key).
130
+ // A non-object value blocking the path is replaced rather than crashing.
131
+ const segs = key.split('.');
132
+ let node = cfg;
133
+ for (let i = 0; i < segs.length - 1; i++) {
134
+ const seg = segs[i];
135
+ if (!node[seg] || typeof node[seg] !== 'object' || Array.isArray(node[seg])) node[seg] = {};
136
+ node = node[seg];
137
+ }
138
+ node[segs[segs.length - 1]] = coerced;
139
+ } else {
140
+ cfg[key] = coerced;
141
+ }
108
142
  writeConfig(cfg);
109
- console.log(JSON.stringify({ ok: true, key, value }));
143
+ console.log(JSON.stringify({ ok: true, key, value: coerced }));
110
144
  }
111
145
  export async function cmdDoctor() {
112
146
  await ensureRegistry();
@@ -0,0 +1,78 @@
1
+ // commands/help_text.mjs — pure-data help tables for `lazyclaw help`,
2
+ // extracted verbatim from commands/setup.mjs (file-size gate, no behavior
3
+ // change). HELP_SUMMARIES (one-liners for `help`) and HELP_DETAILS (per-
4
+ // subcommand usage for `help <name>`) are read only by cmdHelp. They import
5
+ // nothing and reference nothing else — pure data, lowest-risk extraction.
6
+
7
+ // One-line summaries used by `lazyclaw help`. Format keeps it scan-friendly
8
+ // in a 80-column terminal: subcommand padded to 12 chars, then the summary.
9
+ export const HELP_SUMMARIES = {
10
+ run: 'Execute a workflow file (run <session-id> <workflow.mjs>)',
11
+ resume: 'Resume a workflow from its last persisted checkpoint',
12
+ config: 'Manage local config (get|set|list|delete <key>)',
13
+ chat: 'Interactive REPL with the configured provider',
14
+ agent: 'One-shot prompt: streams a single response, exits',
15
+ doctor: 'Print diagnostic JSON; exits non-zero on issues',
16
+ status: 'Print current provider/model/masked key as JSON',
17
+ onboard: 'Guided setup (use --non-interactive for scripts)',
18
+ sessions: 'Persistent chat sessions (list|show|clear|export)',
19
+ skills: 'Markdown skill bundles (list|show|install|remove)',
20
+ providers: 'Inspect / register providers (list|info|test|add|remove|models)',
21
+ daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
22
+ version: 'Print VERSION + node + platform as JSON',
23
+ completion: 'Emit shell completion script (completion <bash|zsh>)',
24
+ export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
25
+ import: 'Apply a JSON bundle from stdin or --from <path>',
26
+ rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
27
+ auth: 'Multiple keys per provider (auth list|add|remove|use|rotate <provider>)',
28
+ pairing: 'Sender allowlist for the messaging surface (pairing list|add|remove <id>)',
29
+ nodes: 'Companion device registration (nodes list|register|remove <id>)',
30
+ message: 'Outbound webhook messaging (message list|add|remove|send <name>)',
31
+ workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
32
+ browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
33
+ cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
34
+ setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
35
+ dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
36
+ inspect: 'Print persisted workflow state without executing',
37
+ clear: 'Delete a persisted workflow state file (idempotent)',
38
+ validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
39
+ graph: 'Emit workflow DAG as Mermaid syntax (paste-ready for docs)',
40
+ orchestrator: 'Multi-agent dispatch — planner decomposes, workers run, planner synthesises',
41
+ };
42
+
43
+ // Detailed usage per subcommand for `lazyclaw help <name>`. Kept as flat
44
+ // strings so the help output is identical in every terminal.
45
+ export const HELP_DETAILS = {
46
+ run: 'Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]\n Default: runPersistent — sequential, persists state, resumable via `lazyclaw resume`.\n --parallel: runParallel — topological-level DAG, in-memory only, NOT resumable.\n --parallel-persistent: runPersistentDag — DAG + checkpoint + resume.\n --concurrency <N>: cap in-flight nodes within a level (DAG modes only). 0/missing → unbounded.\n Workflow file exports `nodes`; deps: string[] declares dependencies for both DAG modes.',
47
+ resume: 'Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]\n Re-enters a previously persisted run; succeeds nodes are skipped.\n Pass --parallel-persistent to resume a DAG run (must match the original run\'s mode).\n --concurrency <N>: cap in-flight nodes per level (DAG mode only).',
48
+ inspect: 'Usage: lazyclaw inspect [<session-id>] [--dir <state-dir>] [--status done|resumable|failed|running] [--summary] [--filter <substr>] [--limit <N>] [--node <node-id>] [--slowest <N>] [--critical-path <workflow.mjs>] [--aggregate]\n With no session-id: list every persisted session in the state dir, sorted by recency.\n --aggregate (list mode): per-node stats across all sessions (count, success/failed/pending/running, min/max/avg/total duration).\n --status filters the listing to a single lifecycle bucket.\n --filter / --limit refine list-mode further (case-insensitive sessionId substring + post-filter cap).\n --summary trims per-node detail in single-session mode (matches list-mode shape).\n --node <id>: print just that node\'s state. Exit 0 success/pending/running, 1 failed, 2 no such node.\n --slowest <N>: top N nodes by durationMs (descending, ties broken by id).\n --critical-path <workflow.mjs>: longest-weighted-path analysis using each node\'s recorded durationMs (bottleneck finder).\n With a session-id (no per-node flag): print full state. Exit code: 0=resumable, 1=fully done, 2=no state, 3=terminal failure.',
49
+ clear: 'Usage: lazyclaw clear <session-id> [--dir <state-dir>]\n Delete the state file for <session-id>. Idempotent — exits 0 whether the file existed or not.\n Refuses sessionIds that resolve outside <state-dir>. Mirrors DELETE /workflows/<id> on the daemon.',
50
+ validate: 'Usage: lazyclaw validate <workflow.mjs>\n Static check: load + shape + dep + cycle + parallelism estimate.\n Exit 0 valid · 1 hard failure (issues populated) · 2 file/import error.',
51
+ graph: 'Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]\n Emit the workflow DAG as Mermaid syntax (graph TD by default; --lr for left-right).\n --state overlays a persisted run\'s status (success ✓ / running ⏳ / failed ✗ / pending) with classDef styling.\n Output is paste-ready for GitHub markdown / Notion / Obsidian.',
52
+ config: 'Usage: lazyclaw config <get|set|list|delete|path|edit|validate> [key] [value]\n Local key-value config at $LAZYCLAW_CONFIG_DIR/config.json (default ~/.lazyclaw).\n `path` prints the file location; `edit` opens it in $EDITOR (or $LAZYCLAW_EDITOR / $VISUAL / vi) and validates JSON on save.\n `validate` checks the structural integrity of the whole config file (typed values, known providers, rate-card shape).',
53
+ chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--workspace <name>] [--pick] [--sandbox docker:<image>] [--sandbox-network <net>] [--sandbox-mount <m>] [--sandbox-env <e>]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md from <configDir>/workspaces/<name>/ into the system prompt.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).\n --sandbox routes the underlying claude CLI through `docker run --rm -i --network <net> -v cwd:cwd ...` (default --network=none).',
54
+ agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--workspace <name>] [--thinking N] [--show-thinking] [--usage] [--cost] [--sandbox docker:<image>]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md into the system prompt (combines with --skill).\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.\n --sandbox docker:<image> wraps the subprocess provider (claude-cli) in a Docker container; --sandbox-network defaults to none.',
55
+ doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
56
+ status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
57
+ onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
58
+ sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
59
+ skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> — single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
60
+ providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
61
+ daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
62
+ version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
63
+ completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
64
+ export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
65
+ import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
66
+ rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
67
+ sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
68
+ auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
69
+ pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
70
+ nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
71
+ message: 'Usage: lazyclaw message <list | add <name> <webhook-url> [--kind slack|discord|generic] | remove <name> | send <name> <text>>\n Outbound webhook messaging — Slack / Discord Incoming Webhooks. Auto-detects kind from the URL pattern.\n send accepts a literal string, or `-` to read the body from stdin.',
72
+ workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
73
+ browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
74
+ cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
75
+ setup: 'Usage: lazyclaw setup [--skip-test] [--only <steps>] [--skip <steps>]\n --only/--skip take a comma list of: provider verify channel workspace skill webhook orchestrator (e.g. --only channel re-runs just that step).\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
76
+ dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
77
+ orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
78
+ };
@@ -25,6 +25,7 @@ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } fr
25
25
  import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
26
26
  import { runChannelStep, runWebhookStep, runOrchestratorStep, runContextStep } from './setup_channels.mjs';
27
27
  import { splashPropsForSetup, renderSplashToString } from '../tui/splash_props.mjs';
28
+ import { HELP_SUMMARIES, HELP_DETAILS } from './help_text.mjs';
28
29
 
29
30
  function applyOnboardConfig(currentCfg, flags) {
30
31
  // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
@@ -97,79 +98,6 @@ export async function cmdOnboard(flags) {
97
98
 
98
99
 
99
100
 
100
- // One-line summaries used by `lazyclaw help`. Format keeps it scan-friendly
101
- // in a 80-column terminal: subcommand padded to 12 chars, then the summary.
102
- const HELP_SUMMARIES = {
103
- run: 'Execute a workflow file (run <session-id> <workflow.mjs>)',
104
- resume: 'Resume a workflow from its last persisted checkpoint',
105
- config: 'Manage local config (get|set|list|delete <key>)',
106
- chat: 'Interactive REPL with the configured provider',
107
- agent: 'One-shot prompt: streams a single response, exits',
108
- doctor: 'Print diagnostic JSON; exits non-zero on issues',
109
- status: 'Print current provider/model/masked key as JSON',
110
- onboard: 'Guided setup (use --non-interactive for scripts)',
111
- sessions: 'Persistent chat sessions (list|show|clear|export)',
112
- skills: 'Markdown skill bundles (list|show|install|remove)',
113
- providers: 'Inspect / register providers (list|info|test|add|remove|models)',
114
- daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
115
- version: 'Print VERSION + node + platform as JSON',
116
- completion: 'Emit shell completion script (completion <bash|zsh>)',
117
- export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
118
- import: 'Apply a JSON bundle from stdin or --from <path>',
119
- rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
120
- auth: 'Multiple keys per provider (auth list|add|remove|use|rotate <provider>)',
121
- pairing: 'Sender allowlist for the messaging surface (pairing list|add|remove <id>)',
122
- nodes: 'Companion device registration (nodes list|register|remove <id>)',
123
- message: 'Outbound webhook messaging (message list|add|remove|send <name>)',
124
- workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
125
- browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
126
- cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
127
- setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
128
- dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
129
- inspect: 'Print persisted workflow state without executing',
130
- clear: 'Delete a persisted workflow state file (idempotent)',
131
- validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
132
- graph: 'Emit workflow DAG as Mermaid syntax (paste-ready for docs)',
133
- orchestrator: 'Multi-agent dispatch — planner decomposes, workers run, planner synthesises',
134
- };
135
-
136
- // Detailed usage per subcommand for `lazyclaw help <name>`. Kept as flat
137
- // strings so the help output is identical in every terminal.
138
- const HELP_DETAILS = {
139
- run: 'Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]\n Default: runPersistent — sequential, persists state, resumable via `lazyclaw resume`.\n --parallel: runParallel — topological-level DAG, in-memory only, NOT resumable.\n --parallel-persistent: runPersistentDag — DAG + checkpoint + resume.\n --concurrency <N>: cap in-flight nodes within a level (DAG modes only). 0/missing → unbounded.\n Workflow file exports `nodes`; deps: string[] declares dependencies for both DAG modes.',
140
- resume: 'Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]\n Re-enters a previously persisted run; succeeds nodes are skipped.\n Pass --parallel-persistent to resume a DAG run (must match the original run\'s mode).\n --concurrency <N>: cap in-flight nodes per level (DAG mode only).',
141
- inspect: 'Usage: lazyclaw inspect [<session-id>] [--dir <state-dir>] [--status done|resumable|failed|running] [--summary] [--filter <substr>] [--limit <N>] [--node <node-id>] [--slowest <N>] [--critical-path <workflow.mjs>] [--aggregate]\n With no session-id: list every persisted session in the state dir, sorted by recency.\n --aggregate (list mode): per-node stats across all sessions (count, success/failed/pending/running, min/max/avg/total duration).\n --status filters the listing to a single lifecycle bucket.\n --filter / --limit refine list-mode further (case-insensitive sessionId substring + post-filter cap).\n --summary trims per-node detail in single-session mode (matches list-mode shape).\n --node <id>: print just that node\'s state. Exit 0 success/pending/running, 1 failed, 2 no such node.\n --slowest <N>: top N nodes by durationMs (descending, ties broken by id).\n --critical-path <workflow.mjs>: longest-weighted-path analysis using each node\'s recorded durationMs (bottleneck finder).\n With a session-id (no per-node flag): print full state. Exit code: 0=resumable, 1=fully done, 2=no state, 3=terminal failure.',
142
- clear: 'Usage: lazyclaw clear <session-id> [--dir <state-dir>]\n Delete the state file for <session-id>. Idempotent — exits 0 whether the file existed or not.\n Refuses sessionIds that resolve outside <state-dir>. Mirrors DELETE /workflows/<id> on the daemon.',
143
- validate: 'Usage: lazyclaw validate <workflow.mjs>\n Static check: load + shape + dep + cycle + parallelism estimate.\n Exit 0 valid · 1 hard failure (issues populated) · 2 file/import error.',
144
- graph: 'Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]\n Emit the workflow DAG as Mermaid syntax (graph TD by default; --lr for left-right).\n --state overlays a persisted run\'s status (success ✓ / running ⏳ / failed ✗ / pending) with classDef styling.\n Output is paste-ready for GitHub markdown / Notion / Obsidian.',
145
- config: 'Usage: lazyclaw config <get|set|list|delete|path|edit|validate> [key] [value]\n Local key-value config at $LAZYCLAW_CONFIG_DIR/config.json (default ~/.lazyclaw).\n `path` prints the file location; `edit` opens it in $EDITOR (or $LAZYCLAW_EDITOR / $VISUAL / vi) and validates JSON on save.\n `validate` checks the structural integrity of the whole config file (typed values, known providers, rate-card shape).',
146
- chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--workspace <name>] [--pick] [--sandbox docker:<image>] [--sandbox-network <net>] [--sandbox-mount <m>] [--sandbox-env <e>]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md from <configDir>/workspaces/<name>/ into the system prompt.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).\n --sandbox routes the underlying claude CLI through `docker run --rm -i --network <net> -v cwd:cwd ...` (default --network=none).',
147
- agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--workspace <name>] [--thinking N] [--show-thinking] [--usage] [--cost] [--sandbox docker:<image>]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md into the system prompt (combines with --skill).\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.\n --sandbox docker:<image> wraps the subprocess provider (claude-cli) in a Docker container; --sandbox-network defaults to none.',
148
- doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
149
- status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
150
- onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
151
- sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
152
- skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> — single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
153
- providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
154
- daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
155
- version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
156
- completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
157
- export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
158
- import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
159
- rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
160
- sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
161
- auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
162
- pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
163
- nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
164
- message: 'Usage: lazyclaw message <list | add <name> <webhook-url> [--kind slack|discord|generic] | remove <name> | send <name> <text>>\n Outbound webhook messaging — Slack / Discord Incoming Webhooks. Auto-detects kind from the URL pattern.\n send accepts a literal string, or `-` to read the body from stdin.',
165
- workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
166
- browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
167
- cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
168
- setup: 'Usage: lazyclaw setup [--skip-test] [--only <steps>] [--skip <steps>]\n --only/--skip take a comma list of: provider verify channel workspace skill webhook orchestrator (e.g. --only channel re-runs just that step).\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
169
- dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
170
- orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
171
- };
172
-
173
101
  export function cmdHelp(name) {
174
102
  if (!name) {
175
103
  process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
@@ -26,6 +26,11 @@ export function accumulateMetricsFromCost(metrics, usage, cost) {
26
26
  if (usage) {
27
27
  if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
28
28
  if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
29
+ // inputTokens is reported NET of cache by the providers, so the cache
30
+ // buckets would otherwise go uncounted. Track them separately to keep the
31
+ // token totals complete (this is metrics-only — cost math is unchanged).
32
+ if (Number.isFinite(usage.cacheReadInputTokens)) metrics.tokensTotal.cacheReadInputTokens += usage.cacheReadInputTokens;
33
+ if (Number.isFinite(usage.cacheCreationInputTokens)) metrics.tokensTotal.cacheCreationInputTokens += usage.cacheCreationInputTokens;
29
34
  }
30
35
  }
31
36
 
@@ -28,6 +28,7 @@ export const ROUTES = [
28
28
  { m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
29
29
  { m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
30
30
  { m: (c) => c.req.method === 'GET' && /^\/avatars\/\d{2}\.png$/.test(c.path || ''), h: meta.avatar },
31
+ { m: (c) => c.req.method === 'GET' && /^\/agent-avatars\/[A-Za-z0-9_-]+\.[a-z]+$/.test(c.path || ''), h: meta.agentAvatar },
31
32
  { m: (c) => c.route === 'GET /version', h: meta.version },
32
33
  { m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
33
34
  { m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
@@ -65,6 +65,35 @@ export async function dashboardJs(c) {
65
65
  return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
66
66
  }
67
67
 
68
+ // Serve a custom agent character image (web app: <img src="/agent-avatars/NN">).
69
+ // These are user-supplied photos copied under <configDir>/agent-avatars/ by
70
+ // `lazyclaw agent set-avatar`. Served from the config dir (NOT the package web/
71
+ // dir) since they're per-install user data. The filename is constrained to
72
+ // `<word>.<ext>` so no path traversal escapes the agent-avatars directory.
73
+ const _AGENT_AVATAR_CT = {
74
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
75
+ '.gif': 'image/gif', '.webp': 'image/webp',
76
+ };
77
+ export async function agentAvatar(c) {
78
+ const { res } = c;
79
+ const m = /^\/agent-avatars\/([A-Za-z0-9_-]+\.[a-z]+)$/.exec(c.path || '');
80
+ const file = m && m[1];
81
+ const ct = file && _AGENT_AVATAR_CT[nodePath.extname(file).toLowerCase()];
82
+ const base = c.gwConfigDir;
83
+ if (!file || file.includes('..') || !ct || !base) {
84
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
85
+ return res.end('not found\n');
86
+ }
87
+ try {
88
+ const body = _readAssetCached(nodePath.join(base, 'agent-avatars', file));
89
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-cache' });
90
+ return res.end(body);
91
+ } catch (e) {
92
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
93
+ return res.end(`not found: ${file} (${e?.message || e})\n`);
94
+ }
95
+ }
96
+
68
97
  export async function dashboard(c) {
69
98
  const { res } = c;
70
99
  // Serve the lazyclaw-only web dashboard (a single static
package/daemon.mjs CHANGED
@@ -102,7 +102,7 @@ export function makeHandler(ctx) {
102
102
  // EUR-priced regional contract) doesn't silently sum mismatched
103
103
  // numbers. Tokens are unit-free so we keep them in a single counter.
104
104
  costsByCurrency: /** @type {Record<string, number>} */({}),
105
- tokensTotal: { inputTokens: 0, outputTokens: 0 },
105
+ tokensTotal: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 },
106
106
  };
107
107
  // Device gateway (Phase 27). The ChallengeRegistry is a per-process
108
108
  // singleton: a challenge minted by one request is consumed by a later
package/lib/args.mjs CHANGED
@@ -277,6 +277,6 @@ _lazyclaw "$@"
277
277
  // AGENT_REG_SUBS to decide whether a bare `agent <sub>` routes to the agent
278
278
  // registry vs a one-shot agent run. TEAM_SUBS/TASK_SUBS mirror their command's
279
279
  // subcommands for the same disambiguation pattern.
280
- export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
280
+ export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth', 'set-avatar']);
281
281
  export const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
282
282
  export const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
@@ -204,6 +204,13 @@ export async function runAgentTurn({
204
204
  iterations++;
205
205
  const resp = await adapter.callOnce({
206
206
  messages, tools, model: agent.model, apiKey, system: systemPrompt,
207
+ // Forward-compat: thread an explicit per-agent output cap when the agent
208
+ // record carries one. No current config/setup path populates
209
+ // agent.maxTokens / agent.maxOutputTokens, so this is `undefined` today —
210
+ // each adapter then falls back to its own DEFAULT (anthropic/openai) or
211
+ // leaves the cap unset (gemini), keeping existing turns byte-stable. When
212
+ // a future config does set it, the cap reaches gemini (FIX D) too.
213
+ maxTokens: agent.maxTokens ?? agent.maxOutputTokens,
207
214
  fetchImpl, baseUrl, signal, cache,
208
215
  });
209
216
  if (resp.text) lastText = resp.text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -179,7 +179,7 @@ export const claudeCliProvider = {
179
179
  });
180
180
  let yielded = false;
181
181
  try {
182
- for await (const chunk of session.send(String(last.content), { signal: opts.signal, onUsage: opts.onUsage })) {
182
+ for await (const chunk of session.send(String(last.content), { signal: opts.signal, onUsage: opts.onUsage, onTruncated: opts.onTruncated })) {
183
183
  yielded = true;
184
184
  yield chunk;
185
185
  }
@@ -78,12 +78,15 @@ class ClaudeSession {
78
78
  }
79
79
 
80
80
  // Stream one turn. Yields text deltas until the matching `result` event.
81
- async *send(userText, { signal, onUsage } = {}) {
81
+ async *send(userText, { signal, onUsage, onTruncated } = {}) {
82
82
  if (this.busy) throw new Error('claude session busy — sends must be serialised per session');
83
83
  if (!this.alive) throw new Error('claude session is not alive');
84
84
  this.busy = true;
85
85
  this._touch();
86
86
 
87
+ // Per-turn usage accumulated from `assistant` events (incl. cache fields);
88
+ // falls back to the result event's usage if no assistant event was seen.
89
+ const u = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, saw: false };
87
90
  const queue = [];
88
91
  let done = false;
89
92
  let error = null;
@@ -100,16 +103,30 @@ class ClaudeSession {
100
103
  try { obj = JSON.parse(line); } catch { continue; }
101
104
  const t = extractTextDelta(obj);
102
105
  if (t) { queue.push(t); if (wake) wake(); }
106
+ if (obj.type === 'assistant' && obj.message && obj.message.usage) {
107
+ const mu = obj.message.usage;
108
+ u.input += mu.input_tokens || 0;
109
+ u.output += mu.output_tokens || 0;
110
+ u.cacheCreate += mu.cache_creation_input_tokens || 0;
111
+ u.cacheRead += mu.cache_read_input_tokens || 0;
112
+ u.saw = true;
113
+ }
103
114
  if (obj.type === 'result') {
104
- if (typeof onUsage === 'function' && obj.usage) {
115
+ if (typeof onUsage === 'function') {
116
+ const ru = obj.usage || {};
105
117
  try {
106
118
  onUsage({
107
- inputTokens: obj.usage.input_tokens || 0,
108
- outputTokens: obj.usage.output_tokens || 0,
119
+ inputTokens: u.saw ? u.input : (ru.input_tokens || 0),
120
+ outputTokens: u.saw ? u.output : (ru.output_tokens || 0),
121
+ cacheCreationInputTokens: u.saw ? u.cacheCreate : (ru.cache_creation_input_tokens || 0),
122
+ cacheReadInputTokens: u.saw ? u.cacheRead : (ru.cache_read_input_tokens || 0),
109
123
  totalCostUsd: obj.total_cost_usd || 0,
110
124
  });
111
125
  } catch { /* never fail a turn on a usage callback */ }
112
126
  }
127
+ if (typeof onTruncated === 'function' && obj.is_error && /max_turns/.test(String(obj.subtype || ''))) {
128
+ try { onTruncated('max_turns'); } catch { /* ditto */ }
129
+ }
113
130
  done = true; if (wake) wake();
114
131
  }
115
132
  }
@@ -140,15 +140,18 @@ function extractUsage(obj) {
140
140
  // OpenAI Responses-API convention (codex follows it): input_tokens already
141
141
  // INCLUDES cached_input_tokens, and output_tokens already INCLUDES
142
142
  // reasoning_output_tokens — they are subset breakdowns, not additive. Summing
143
- // them double-counts and trips the cost cap early. Report the totals and
144
- // surface cached input separately so it can bill at the cache-read rate.
145
- const input = u.input_tokens ?? 0;
143
+ // them double-counts and trips the cost cap early. Report NET (non-cached)
144
+ // input total minus the cached subset to match Anthropic's convention so
145
+ // rates.mjs's single cost formula doesn't bill the cached tokens at BOTH the
146
+ // input rate and the cache-read rate. Surface the cached subset separately.
147
+ const cacheRead = u.cached_input_tokens ?? 0;
148
+ const input = (u.input_tokens ?? 0) - cacheRead;
146
149
  const output = u.output_tokens ?? 0;
147
- if (!input && !output) return null;
150
+ if (!input && !output && !cacheRead) return null;
148
151
  return {
149
152
  inputTokens: input,
150
153
  outputTokens: output,
151
- cacheReadInputTokens: u.cached_input_tokens ?? 0,
154
+ cacheReadInputTokens: cacheRead,
152
155
  totalCostUsd: 0,
153
156
  };
154
157
  }
@@ -268,7 +268,10 @@ export const geminiProvider = {
268
268
  if (lastUsage && typeof opts.onUsage === 'function') {
269
269
  try {
270
270
  opts.onUsage({
271
- inputTokens: lastUsage.promptTokenCount ?? null,
271
+ // promptTokenCount is cache-INCLUSIVE; report NET (minus cached) to
272
+ // match Anthropic's convention so rates.mjs doesn't bill the cached
273
+ // tokens at BOTH the input rate and the cache-read rate.
274
+ inputTokens: (lastUsage.promptTokenCount ?? 0) - (lastUsage.cachedContentTokenCount ?? 0),
272
275
  outputTokens: lastUsage.candidatesTokenCount ?? null,
273
276
  cacheReadInputTokens: lastUsage.cachedContentTokenCount ?? 0,
274
277
  totalCostUsd: 0,
@@ -103,13 +103,17 @@ function extractUsage(stats) {
103
103
  let input = 0, output = 0, cached = 0;
104
104
  for (const m of Object.values(models)) {
105
105
  const t = m?.tokens || {};
106
- input += (t.prompt ?? t.input ?? 0);
106
+ // cached is a subset of prompt → report NET (prompt minus cached) so it
107
+ // matches Anthropic's convention and rates.mjs doesn't bill the cached
108
+ // tokens at BOTH the input rate and the cache-read rate. Surface cached
109
+ // separately so it can still bill at the cache-read rate.
110
+ input += (t.prompt ?? t.input ?? 0) - (t.cached ?? 0);
107
111
  // thoughts = reasoning tokens, billed as output and reported separately from
108
- // candidates (additive). cached is a subset of prompt → surface, don't sum.
112
+ // candidates (additive).
109
113
  output += (t.candidates ?? t.output ?? 0) + (t.thoughts ?? 0);
110
114
  cached += (t.cached ?? 0);
111
115
  }
112
- if (!input && !output) return null;
116
+ if (!input && !output && !cached) return null;
113
117
  return { inputTokens: input, outputTokens: output, cacheReadInputTokens: cached, totalCostUsd: 0 };
114
118
  }
115
119
 
@@ -217,6 +221,16 @@ export const geminiCliProvider = {
217
221
  // so surface it instead of silently yielding nothing.
218
222
  const text = typeof parsed.response === 'string' ? parsed.response : '';
219
223
  if (!text && parsed.error) {
224
+ // A failed turn is still METERED: gemini-cli puts the partial token
225
+ // usage in the SAME JSON's `stats` even when the turn errored out. Bill
226
+ // it BEFORE throwing so a failed-but-metered turn still reaches the cost
227
+ // cap (the error path used to throw before extractUsage ever ran, so a
228
+ // failed turn leaked its tokens past accounting). Guarded so a usage
229
+ // callback can never mask the real error.
230
+ const failedUsage = extractUsage(parsed.stats);
231
+ if (failedUsage && typeof opts.onUsage === 'function') {
232
+ try { opts.onUsage(failedUsage); } catch (_) { /* never let usage hide the error */ }
233
+ }
220
234
  const em = parsed.error?.message
221
235
  || (typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error));
222
236
  throw new CliExitError(exitInfo.code, exitInfo.signal, em);
@@ -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,
@@ -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',
@@ -146,7 +154,10 @@ export function parseResponse(json) {
146
154
  const um = json?.usageMetadata;
147
155
  const usage = um
148
156
  ? {
149
- inputTokens: um.promptTokenCount || 0,
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),
150
161
  outputTokens: um.candidatesTokenCount || 0,
151
162
  cacheReadInputTokens: um.cachedContentTokenCount || 0,
152
163
  }
@@ -131,6 +131,13 @@ export function parseResponse(json) {
131
131
  else { try { input = JSON.parse(a); } catch (e) { input = {}; parseError = `malformed tool arguments: ${e.message}`; } }
132
132
  } else if (a && typeof a === 'object') {
133
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}`;
134
141
  }
135
142
  return { id: tc.id, name: tc?.function?.name, input, ...(parseError ? { parseError } : {}) };
136
143
  });