Compare commits

..
3 Commits
Author SHA1 Message Date
Peter Steinberger 7a5e3662a3 fix(web): show all WhatsApp shared contacts 2026-01-10 00:16:54 +00:00
Peter Steinberger d3d6d80f4a fix(agents): require raw for gateway config.apply (#566) (thanks @sircrumpet) 2026-01-10 00:16:17 +00:00
Eugene (via Claudius) a51948a528 fix(tools): flatten gateway tool schema for Vertex AI compatibility
Claude API on Vertex AI (Cloud Code Assist / Antigravity) enforces strict
JSON Schema 2020-12 validation and rejects root-level anyOf without a
top-level type field.

TypeBox Type.Union compiles to { anyOf: [...] } which Anthropic's direct
API accepts but Vertex rejects with:
  tools.11.custom.input_schema: JSON schema is invalid

This follows the same pattern used in browser-tool.ts which has the same
fix with an explanatory comment.

Flatten the schema to Type.Object with an action enum, matching how
browser tool handles this constraint.
2026-01-10 00:16:17 +00:00
121 changed files with 1323 additions and 4862 deletions

No files matched your search

+6 -57
View File
@@ -5,57 +5,6 @@ on:
pull_request:
jobs:
install-check:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Runtime versions
run: |
node -v
npm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
checks:
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
@@ -105,7 +54,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24
check-latest: true
- name: Setup Bun
@@ -136,7 +85,7 @@ jobs:
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -184,7 +133,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24
check-latest: true
- name: Setup Bun
@@ -215,7 +164,7 @@ jobs:
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -251,7 +200,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
node-version: 24
check-latest: true
- name: Runtime versions
@@ -276,7 +225,7 @@ jobs:
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
-3
View File
@@ -34,8 +34,6 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + what’s covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
@@ -43,7 +41,6 @@
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
+179 -56
View File
@@ -1,62 +1,185 @@
# Changelog
## 2026.1.9
### Highlights
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
### Breaking
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
### New Features and Changes
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
- Providers: Signal reactions + notifications with allowlist support.
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
- TUI: agent picker + agents list RPC; improved status line.
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
### Fixes
- Packaging: include MS Teams send module in npm tarball.
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
- iMessage: isolate group-ish threads by chat_id.
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
### Maintenance
- Dependencies: bump pi-* stack to 0.42.2.
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker.
## Unreleased
- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
- Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete
- macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos
- macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis
- Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete
- Onboarding: QuickStart provider picker uses single-select to avoid accidental Telegram token prompts when choosing WhatsApp. (#485) — thanks @frankstallone
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
- CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
- Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
- Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete
- Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Telegram: keep streamMode draft-only; avoid forcing block streaming. (#619) — thanks @rubyrunsstuff
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos
- WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365)
- WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Config: write `clawdbot.json` atomically (temp file + replace) and keep a best-effort `.bak` backup.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Agent: drop empty error assistant messages when sanitizing session history. (#591) — thanks @steipete
- Agent: inject eligible skills list into the system prompt so bundled skills load from their actual locations. (#551) — thanks @gabriel-trigo
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Cron: enqueue main-session system events under the resolved main session key. (#510)
- Mobile: centralize main session key normalization for iOS/Android runtime helpers. — thanks @steipete
- Chat UI: stop pinning hardcoded `main` session in the recent list; prefer active session if missing. — thanks @steipete
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
- Agents: require `raw` for gateway `config.apply` tool calls while keeping schema 2020-12 compatible. (#566) — thanks @sircrumpet
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
- Auto-reply: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
- Auto-reply: suppress `<think>` leakage in block streaming and emit `/reasoning` as a separate `Reasoning:` message. (#614) — thanks @zknicker
- Auto-reply: default block streaming off for non-Telegram providers unless explicitly enabled, and avoid splitting on forced flushes below max.
- Auto-reply: raise default coalesce minChars for Signal/Slack/Discord and clarify streaming vs draft streaming in docs.
- Auto-reply: default block streaming coalesce idle to 1s to reduce tiny chunks. — thanks @steipete
- Auto-reply: fix /status usage summary filtering for the active provider.
- Auto-reply: deduplicate followup queue entries using message id/routing to avoid duplicate replies. (#600) — thanks @samratjha96
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- Status: show active auth profile and key snippet in /status.
- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
- Control UI: logs tab opens at the newest entries (bottom).
- Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
- Control UI: add Docs link, remove chat composer divider, and add New session button.
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
- Signal: add reaction notifications with allowlist support. — thanks @steipete
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
- Commands: add /debug for runtime config overrides (memory-only).
- Daemon runtime: remove Bun from selection options.
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- TUI: stop overriding agent timeout so config defaults apply; warn on invalid `--timeout-ms`. (#549)
- Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
- Commands: return /status in directive-only multi-line messages.
- Models: fall back to configured models when the provider catalog is unavailable.
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
- Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
- Configure: add wizard mode to remove a provider config block. — thanks @steipete
- Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
- Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
- Docs: split CLI install commands into separate code blocks. (#601) — thanks @martinpucik
- WhatsApp: record outbound provider activity using the active account id. (#537) — thanks @Nachx639
- Discord: add gateway HELLO timeout to detect zombie connections. (#608) — thanks @NicholasSpisak
- Docker: cache dependency layer for faster rebuilds. (#605) — thanks @zknicker
## 2026.1.8
-1
View File
@@ -14,7 +14,6 @@ RUN apt-get update \
jq \
novnc \
python3 \
socat \
websockify \
x11vnc \
xvfb \
-1
View File
@@ -153,7 +153,6 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
Highlights:
- `/status` for quick diagnostics.
- `/config` for persisted config changes.
- `/debug` for runtime-only config overrides (memory, not disk).
## Setup + onboarding
-2
View File
@@ -58,8 +58,6 @@ If you have both an OAuth profile and an API key profile for the same provider,
When a profile fails due to auth/rate‑limit errors (or a timeout that looks
like rate limiting), Clawdbot marks it in cooldown and moves to the next profile.
Format/invalid‑request errors (for example Cloud Code Assist tool call ID
validation failures) are treated as failover‑worthy and use the same cooldowns.
Cooldowns use exponential backoff:
- 1 minute
-3
View File
@@ -33,9 +33,6 @@ Related:
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
Provider configuration examples (including OpenCode Zen) live in
[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
## “Model is not allowed” (and why replies stop)
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for
+4 -11
View File
@@ -54,11 +54,11 @@ debugging. There are **two** `--dev` flags:
- **`gateway --dev`: tells the Gateway to auto-create a default config +
workspace** when missing (and skip BOOTSTRAP.md).
Recommended flow (dev profile + dev bootstrap):
Recommended flow:
```bash
pnpm gateway:dev
CLAWDBOT_PROFILE=dev pnpm clawdbot tui
pnpm clawdbot --dev gateway --dev
pnpm clawdbot --dev tui
```
What this does:
@@ -80,14 +80,7 @@ What this does:
Reset flow (fresh start):
```bash
pnpm gateway:dev:reset
```
Note: `--dev` is a **global** profile flag and gets eaten by some runners.
If you need to spell it out, use the env var form:
```bash
CLAWDBOT_PROFILE=dev pnpm clawdbot gateway --dev --reset
pnpm clawdbot --dev gateway --dev --reset
```
`--reset` wipes config, credentials, sessions, and the dev workspace (using
-1
View File
@@ -718,7 +718,6 @@
{
"group": "Reference & Templates",
"pages": [
"testing",
"scripts",
"reference/rpc",
"reference/device-models",
+2 -49
View File
@@ -948,8 +948,7 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
messages: {
responsePrefix: "🦞", // or "auto"
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: false
ackReactionScope: "group-mentions"
}
}
```
@@ -976,9 +975,6 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
- `direct`: direct messages only
- `all`: all messages
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
(Slack/Discord/Telegram only). Default: `false`.
### `talk`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
@@ -1354,9 +1350,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
vncPort: 5900,
noVncPort: 6080,
headless: false,
enableNoVnc: true,
autoStart: true,
autoStartTimeoutMs: 12000
enableNoVnc: true
},
prune: {
idleHours: 24, // 0 disables idle pruning
@@ -1445,47 +1439,6 @@ Select the model via `agents.defaults.model.primary` (provider/model).
}
```
### OpenCode Zen (multi-model proxy)
OpenCode Zen is an OpenAI-compatible proxy at `https://opencode.ai/zen/v1`. Get an API key at https://opencode.ai/auth and set `OPENCODE_ZEN_API_KEY`.
Notes:
- Model refs use `opencode-zen/<modelId>` (example: `opencode-zen/claude-opus-4-5`).
- If you enable an allowlist via `agents.defaults.models`, add each model you plan to use.
- Shortcut: `clawdbot onboard --auth-choice opencode-zen`.
```json5
{
agents: {
defaults: {
model: { primary: "opencode-zen/claude-opus-4-5" },
models: { "opencode-zen/claude-opus-4-5": { alias: "Opus" } }
}
},
models: {
mode: "merge",
providers: {
"opencode-zen": {
baseUrl: "https://opencode.ai/zen/v1",
apiKey: "${OPENCODE_ZEN_API_KEY}",
api: "openai-completions",
models: [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000
}
]
}
}
}
}
```
### Z.AI (GLM-4.7) — provider alias support
Z.AI models are available via the built-in `zai` provider. Set `ZAI_API_KEY`
-2
View File
@@ -19,8 +19,6 @@ and process access when the model does something dumb.
## What gets sandboxed
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
- Optional sandboxed browser (`agents.defaults.sandbox.browser`).
- By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it.
Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`.
Not sandboxed:
- The Gateway process itself.
+4 -31
View File
@@ -1,7 +1,6 @@
---
summary: "Run Clawdbot Gateway 24/7 on a cheap Hetzner VPS (Docker) with durable state and baked-in binaries"
summary: "Run Clawdbot Gateway on Hetzner (Docker + VPS) with durable state and baked-in binaries"
read_when:
- You want Clawdbot running 24/7 on a cloud VPS (not your laptop)
- You want a production-grade, always-on Gateway on your own VPS
- You want full control over persistence, binaries, and restart behavior
- You are running Clawdbot in Docker on Hetzner or a similar provider
@@ -12,17 +11,6 @@ read_when:
## Goal
Run a persistent Clawdbot Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
If you want “Clawdbot 24/7 for ~$5”, this is the simplest reliable setup.
Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS and scale up if you hit OOMs.
## What are we doing (simple terms)?
- Rent a small Linux server (Hetzner VPS)
- Install Docker (isolated app runtime)
- Start the Clawdbot Gateway in Docker
- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
- Access the Control UI from your laptop via an SSH tunnel
The Gateway can be accessed via:
- SSH port forwarding from your laptop
- Direct port exposure if you manage firewalling and tokens yourself
@@ -50,8 +38,6 @@ For the generic Docker flow, see [Docker](/install/docker).
- Hetzner VPS with root access
- SSH access from your laptop
- Basic comfort with SSH + copy/paste
- ~20 minutes
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials
@@ -138,12 +124,6 @@ GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.clawdbot
```
Generate strong secrets:
```bash
openssl rand -hex 32
```
**Do not commit this file.**
---
@@ -175,14 +155,9 @@ services:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
ports:
# Recommended: keep the Gateway loopback-only on the VPS; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
# Optional: only if you run iOS/Android nodes against this VPS.
# If you expose these publicly, read /gateway/security and firewall accordingly.
# - "${CLAWDBOT_BRIDGE_PORT}:18790"
# - "18793:18793"
- "${CLAWDBOT_GATEWAY_PORT}:18789"
- "${CLAWDBOT_BRIDGE_PORT}:18790"
- "18793:18793"
command:
[
"node",
@@ -252,8 +227,6 @@ RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
ENV NODE_ENV=production
CMD ["node","dist/index.js"]
```
+1 -2
View File
@@ -225,8 +225,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
```
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
ack reaction after the bot replies.
`messages.ackReactionScope`.
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
+1 -2
View File
@@ -192,8 +192,7 @@ Tokens can also be supplied via env vars:
- `SLACK_APP_TOKEN`
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
ack reaction after the bot replies.
`messages.ackReactionScope`.
## Limits
- Outbound text is chunked to `slack.textChunkLimit` (default 4000).
+1 -1
View File
@@ -287,4 +287,4 @@ Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
- `messages.groupChat.mentionPatterns` (global fallback).
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`, `messages.removeAckAfterReply`.
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`.
-2
View File
@@ -5,8 +5,6 @@ read_when:
---
# Tests
- Full testing kit (suites, live, Docker): [Testing](/testing)
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs Vitest with V8 coverage. Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing).
-98
View File
@@ -1,98 +0,0 @@
---
summary: "Testing kit: unit/e2e/live suites, Docker runners, and what each test covers"
read_when:
- Running tests locally or in CI
- Adding regressions for model/provider bugs
- Debugging gateway + agent behavior
---
# Testing
Clawdbot has three Vitest suites (unit, e2e, live) plus a couple Docker helpers for “run with my real keys” smoke checks.
## Quick start
- Full gate (what we expect before push): `pnpm lint && pnpm build && pnpm test`
- Coverage gate: `pnpm test:coverage`
- E2E suite: `pnpm test:e2e`
- Live suite (opt-in, Clawdbot only): `CLAWDBOT_LIVE_TEST=1 pnpm test:live`
- Live suite (opt-in, includes provider live tests too): `LIVE=1 pnpm test:live`
## Test suites (what runs where)
### Unit / integration (default)
- Command: `pnpm test`
- Config: `vitest.config.ts`
- Files: `src/**/*.test.ts`
- Scope: pure unit tests + in-process integration tests (gateway server auth, routing, tooling, parsing, config).
### E2E (gateway smoke)
- Command: `pnpm test:e2e`
- Config: `vitest.e2e.config.ts`
- Files: `src/**/*.e2e.test.ts`
- Scope: multi-instance gateway end-to-end behavior (WebSocket/HTTP/node pairing), heavier networking surface.
### Live (real providers + real models)
- Command: `pnpm test:live`
- Config: `vitest.live.config.ts`
- Files: `src/**/*.live.test.ts`
- Default: **skipped** unless `CLAWDBOT_LIVE_TEST=1` or `LIVE=1`
- Scope: “does this provider/model actually work today with real creds”.
## Live: model smoke (profile keys)
Two layers:
1. Direct model completion (no gateway):
- Test: `src/agents/models.profiles.live.test.ts`
- Goal: enumerate discovered models, use `getApiKeyForModel` to pick ones you have creds for, then run a small completion.
- Selection:
- `CLAWDBOT_LIVE_ALL_MODELS=1` (required to run the suite)
- `CLAWDBOT_LIVE_MODELS=all` or comma allowlist (`openai/gpt-5.2,anthropic/claude-opus-4-5,...`)
- `CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not ad-hoc env).
- Regression hook: OpenAI Responses tool-only → follow-up path (the `reasoning` replay class) is covered here.
2. Gateway + dev agent smoke (what “@clawdbot” actually does):
- Test: `src/gateway/gateway-models.profiles.live.test.ts`
- Goal: spin up an in-process gateway, create/patch a `agent:dev:*` session, iterate models-with-keys, and assert “meaningful” responses.
- Covers providers present in your `models.json`/config (e.g. OpenAI, Anthropic, Google Gemini, `google-antigravity`, etc.) as long as a key/profile is available.
- Selection:
- `CLAWDBOT_LIVE_GATEWAY=1`
- `CLAWDBOT_LIVE_GATEWAY_ALL_MODELS=1` (scan all discovered models with available keys)
- `CLAWDBOT_LIVE_GATEWAY_MODELS=all` or comma allowlist
- Extra regression: for OpenAI Responses/Codex Responses models, force a tool-call-only turn followed by a user question (the exact failure mode that produced `400 … reasoning … required following item`).
## Credentials (never commit)
Live tests discover credentials the same way the CLI does:
- Profile store: `~/.clawdbot/credentials/` (preferred; what “profile keys” means in the tests)
- Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`)
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
## Docker runners (optional “works in Linux” checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
Useful env vars:
- `CLAWDBOT_CONFIG_DIR=...` (default: `~/.clawdbot`) mounted to `/home/node/.clawdbot`
- `CLAWDBOT_WORKSPACE_DIR=...` (default: `~/clawd`) mounted to `/home/node/clawd`
- `CLAWDBOT_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
- `CLAWDBOT_LIVE_GATEWAY_MODELS=...` / `CLAWDBOT_LIVE_MODELS=...` to narrow the run
- `CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
## Docs sanity
Run docs checks after doc edits: `pnpm docs:list`.
## Offline regression (CI-safe)
- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts`
-19
View File
@@ -39,7 +39,6 @@ Text + native (when enabled):
- `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/config show|get|set|unset` (persist config to disk, owner-only)
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
- `/cost on|off` (toggle per-response usage line)
- `/stop`
@@ -83,24 +82,6 @@ Notes:
- Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`.
- Use `/debug reset` to clear all overrides and return to the on-disk config.
## Config updates
`/config` writes to your on-disk config (`clawdbot.json`). Owner-only.
Examples:
```
/config show
/config show messages.responsePrefix
/config get messages.responsePrefix
/config set messages.responsePrefix="[clawdbot]"
/config unset messages.responsePrefix
```
Notes:
- Config is validated before write; invalid changes are rejected.
- `/config` updates persist across restarts.
## Surface notes
- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
+15 -18
View File
@@ -25,7 +25,6 @@
"dist/infra/**",
"dist/macos/**",
"dist/media/**",
"dist/msteams/**",
"dist/process/**",
"dist/sessions/**",
"dist/providers/**",
@@ -49,8 +48,7 @@
"dist/utils/**",
"dist/logging/**",
"dist/markdown/**",
"dist/pairing/**",
"dist/whatsapp/**"
"dist/pairing/**"
],
"scripts": {
"dev": "tsx src/entry.ts",
@@ -67,10 +65,6 @@
"start": "tsx src/entry.ts",
"clawdbot": "tsx src/entry.ts",
"gateway:watch": "tsx watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --dev",
"gateway:dev:reset": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --reset",
"tui": "tsx src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
@@ -98,9 +92,6 @@
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "vitest run --config vitest.live.config.ts",
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
"test:docker:live-models": "bash scripts/test-live-models-docker.sh",
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"protocol:gen": "tsx scripts/protocol-gen.ts",
@@ -121,10 +112,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.42.2",
"@mariozechner/pi-ai": "^0.42.2",
"@mariozechner/pi-coding-agent": "^0.42.2",
"@mariozechner/pi-tui": "^0.42.2",
"@mariozechner/pi-agent-core": "^0.42.1",
"@mariozechner/pi-ai": "^0.42.1",
"@mariozechner/pi-coding-agent": "^0.42.1",
"@mariozechner/pi-tui": "^0.42.1",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -192,12 +183,10 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@buape/carbon@0.0.0-beta-20260109194934": "patches/@buape__carbon@0.0.0-beta-20260109194934.patch",
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-ai@0.42.2": "patches/@mariozechner__pi-ai@0.42.2.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch",
"qrcode-terminal": "patches/qrcode-terminal.patch"
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
}
},
"vitest": {
@@ -231,5 +220,13 @@
"apps/macos/.build/**",
"dist/Clawdbot.app/**"
]
},
"patchedDependencies": {
"@buape/carbon": "patches/@buape__carbon.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"qrcode-terminal": "patches/qrcode-terminal.patch",
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
}
}
@@ -1,8 +1,6 @@
diff --git a/dist/src/classes/RequestClient.js b/dist/src/classes/RequestClient.js
index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6cd6e374e0 100644
--- a/dist/src/classes/RequestClient.js
+++ b/dist/src/classes/RequestClient.js
@@ -118,6 +118,9 @@ export class RequestClient {
@@ -118,6 +118,9 @@
}
}
this.abortController = new AbortController();
@@ -12,7 +10,7 @@ index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6c
let body;
if (data?.body &&
typeof data.body === "object" &&
@@ -178,12 +181,26 @@ export class RequestClient {
@@ -178,12 +181,26 @@
body = JSON.stringify(data.body);
}
}
@@ -45,3 +43,11 @@ index 4a3357a3a71e993e49dbf4511be7f39aeec03755..cae09147c4016ff15ee09c7a0c679a6c
let rawBody = "";
let parsedBody;
try {
@@ -405,4 +422,4 @@
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
-//# sourceMappingURL=RequestClient.js.map
\ No newline at end of file
+//# sourceMappingURL=RequestClient.js.map
+17
View File
@@ -0,0 +1,17 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 0000000..1111111 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,12 @@ async function* streamGeminiCli(model, context, credentials, options) {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // PATCH: Fail immediately on 429 for Antigravity to let caller rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && response.status === 429) {
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
-57
View File
@@ -1,57 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && response.status === 429) {
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644
--- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js
@@ -433,9 +433,15 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
// Do not submit thinking blocks if the completion had an error (i.e. abort)
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
-57
View File
@@ -1,57 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && response.status === 429) {
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644
--- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js
@@ -433,9 +433,15 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
// Do not submit thinking blocks if the completion had an error (i.e. abort)
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
+35 -44
View File
@@ -8,24 +8,15 @@ overrides:
'@sinclair/typebox': 0.34.47
patchedDependencies:
'@buape/carbon@0.0.0-beta-20260109194934':
hash: c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3
path: patches/@buape__carbon@0.0.0-beta-20260109194934.patch
'@mariozechner/pi-agent-core':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch
'@mariozechner/pi-ai@0.42.2':
hash: 0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c
path: patches/@mariozechner__pi-ai@0.42.2.patch
'@mariozechner/pi-ai':
hash: 3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent':
hash: 58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e
path: patches/@mariozechner__pi-coding-agent.patch
playwright-core@1.57.0:
hash: 66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02
path: patches/playwright-core@1.57.0.patch
qrcode-terminal:
hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12
path: patches/qrcode-terminal.patch
importers:
@@ -33,7 +24,7 @@ importers:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260109194934
version: 0.0.0-beta-20260109194934(patch_hash=c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3)(hono@4.11.3)
version: 0.0.0-beta-20260109194934(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -47,17 +38,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.42.2
version: 0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.42.2
version: 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.42.2
version: 0.42.2(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.42.1
version: 0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.42.2
version: 0.42.2
specifier: ^0.42.1
version: 0.42.1
'@microsoft/agents-hosting':
specifier: ^1.1.1
version: 1.1.1
@@ -132,13 +123,13 @@ importers:
version: 0.2.0
playwright-core:
specifier: 1.57.0
version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
version: 1.57.0
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12)
version: 0.12.0
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -860,22 +851,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.42.2':
resolution: {integrity: sha512-j81u9v6FhNgYXTTcgM5FB7f2hLQPc/73oTM0LuRDlybJakDRM3z/BBGkRk5csCfjYN1OeEGMjqj61pR30WqOZg==}
'@mariozechner/pi-agent-core@0.42.1':
resolution: {integrity: sha512-sIRB1jHheQSGONoVorrQ3X9vc1JFLoAe+48k7UJmpLAhJXnQmRtq1+CtzvKOVe5qVRyNBNuNdXipH+seicbwvQ==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.42.2':
resolution: {integrity: sha512-uIPfOCGSWm8Uo4kJ0nWKClJlIhBFxDasBbbAzzEgR9NrsyZMHuFYn4Y2XsbNKHmc4KnoS6DZvaO6IP+/+IS9rw==}
'@mariozechner/pi-ai@0.42.1':
resolution: {integrity: sha512-uRiH+s7EPDz9Q7hjQJ4Mm8lU4e4/C8pB4rnqGds73B5/0rqZb8DeSBukKU6uOwMNkXol/tYgnkEqXy7qEoHLJQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.42.2':
resolution: {integrity: sha512-Rlxo2rWU4RjwBe4jr/CnqqknbDF3fnb3RzCe9u1mMm4nb2T9qLP3QGDo2c5DhWEpcRZ54hrR6HTennlEazfXvA==}
'@mariozechner/pi-coding-agent@0.42.1':
resolution: {integrity: sha512-BsLgFBC//98EdQCCoNYPfxKi2TEJ64a9w/CQC8/DyeAVhjrdJELUd+3EoRV6iiodbpUspUASW9w3ELoqQGAtoA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.42.2':
resolution: {integrity: sha512-im3HwwKvSlh+N1hJpKVoZYW1JrXz0N3eN/PfpWX8DJYYTTG4RDaT0Mbbf0vUbkKSqCfeJQZCN5bfCocTXlU8wA==}
'@mariozechner/pi-tui@0.42.1':
resolution: {integrity: sha512-dsmzcOjD+9MNgqnJ6/BJ255Sc9KQWN4Asyh1ron7bMA4Q9lqSWl6q8dk78DBp2QcVeWHtvqbwdZwNnDwWyxw3g==}
engines: {node: '>=20.0.0'}
'@microsoft/agents-activity@1.1.1':
@@ -3383,7 +3374,7 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260109194934(patch_hash=c36ae878bfd2e2ec2d6eb4fa12ebcd3b0797b033020c0a95d8a3e67ba82228e3)(hono@4.11.3)':
'@buape/carbon@0.0.0-beta-20260109194934(hono@4.11.3)':
dependencies:
'@types/node': 24.10.4
discord-api-types: 0.38.36
@@ -3790,10 +3781,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.2
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.1
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3802,7 +3793,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
@@ -3822,12 +3813,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.42.2(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.42.1(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.42.2(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.42.2(patch_hash=0786e946616db65fea37764750c64745131880092ccd082a0016cc9a8788702c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.2
'@mariozechner/pi-agent-core': 0.42.1(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.42.1(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.42.1
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
@@ -3846,7 +3837,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.42.2':
'@mariozechner/pi-tui@0.42.1':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -5689,11 +5680,11 @@ snapshots:
dependencies:
pngjs: 7.0.0
playwright-core@1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02): {}
playwright-core@1.57.0: {}
playwright@1.57.0:
dependencies:
playwright-core: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
playwright-core: 1.57.0
optionalDependencies:
fsevents: 2.3.2
@@ -5778,7 +5769,7 @@ snapshots:
'@thi.ng/bitstream': 2.4.37
optional: true
qrcode-terminal@0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12): {}
qrcode-terminal@0.12.0: {}
qs@6.14.1:
dependencies:
+1 -1
View File
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.42.1': patches/@mariozechner__pi-ai@0.42.1.patch
'@mariozechner/pi-ai': patches/@mariozechner__pi-ai.patch
+1 -6
View File
@@ -5,12 +5,7 @@ import { execSync } from "node:child_process";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
const requiredPaths = [
"dist/discord/send.js",
"dist/hooks/gmail.js",
"dist/msteams/send.js",
"dist/whatsapp/normalize.js",
];
const requiredPaths = ["dist/discord/send.js", "dist/hooks/gmail.js"];
const forbiddenPrefixes = ["dist/Clawdbot.app/"];
function runPackDry(): PackResult[] {
+2 -19
View File
@@ -25,15 +25,9 @@ else
CHROME_ARGS=()
fi
if [[ "${CDP_PORT}" -ge 65535 ]]; then
CHROME_CDP_PORT="$((CDP_PORT - 1))"
else
CHROME_CDP_PORT="$((CDP_PORT + 1))"
fi
CHROME_ARGS+=(
"--remote-debugging-address=127.0.0.1"
"--remote-debugging-port=${CHROME_CDP_PORT}"
"--remote-debugging-address=0.0.0.0"
"--remote-debugging-port=${CDP_PORT}"
"--user-data-dir=${HOME}/.chrome"
"--no-first-run"
"--no-default-browser-check"
@@ -48,17 +42,6 @@ CHROME_ARGS+=(
chromium "${CHROME_ARGS[@]}" about:blank &
for _ in $(seq 1 50); do
if curl -sS --max-time 1 "http://127.0.0.1:${CHROME_CDP_PORT}/json/version" >/dev/null; then
break
fi
sleep 0.1
done
socat \
TCP-LISTEN:"${CDP_PORT}",fork,reuseaddr,bind=0.0.0.0 \
TCP:127.0.0.1:"${CHROME_CDP_PORT}" &
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost &
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
CONFIG_DIR="${CLAWDBOT_CONFIG_DIR:-$HOME/.clawdbot}"
WORKSPACE_DIR="${CLAWDBOT_WORKSPACE_DIR:-$HOME/clawd}"
PROFILE_FILE="${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}"
PROFILE_MOUNT=()
if [[ -f "$PROFILE_FILE" ]]; then
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro)
fi
echo "==> Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
echo "==> Run gateway live model tests (profile keys)"
docker run --rm -t \
--entrypoint bash \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_GATEWAY=1 \
-e CLAWDBOT_LIVE_GATEWAY_ALL_MODELS=1 \
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
"$IMAGE_NAME" \
-lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live"
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
CONFIG_DIR="${CLAWDBOT_CONFIG_DIR:-$HOME/.clawdbot}"
WORKSPACE_DIR="${CLAWDBOT_WORKSPACE_DIR:-$HOME/clawd}"
PROFILE_FILE="${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}"
PROFILE_MOUNT=()
if [[ -f "$PROFILE_FILE" ]]; then
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro)
fi
echo "==> Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
echo "==> Run live model tests (profile keys)"
docker run --rm -t \
--entrypoint bash \
-e HOME=/home/node \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_ALL_MODELS=1 \
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
-v "$CONFIG_DIR":/home/node/.clawdbot \
-v "$WORKSPACE_DIR":/home/node/clawd \
"${PROFILE_MOUNT[@]}" \
"$IMAGE_NAME" \
-lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live"
+1 -25
View File
@@ -3,11 +3,7 @@ import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
@@ -53,26 +49,6 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
export function resolveSessionAgentIds(params: {
sessionKey?: string;
config?: ClawdbotConfig;
}): { defaultAgentId: string; sessionAgentId: string } {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const sessionAgentId = parsed?.agentId
? normalizeAgentId(parsed.agentId)
: defaultAgentId;
return { defaultAgentId, sessionAgentId };
}
export function resolveSessionAgentId(params: {
sessionKey?: string;
config?: ClawdbotConfig;
}): string {
return resolveSessionAgentIds(params).sessionAgentId;
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
-1
View File
@@ -74,7 +74,6 @@ export type AuthProfileCredential =
export type AuthProfileFailureReason =
| "auth"
| "format"
| "rate_limit"
| "billing"
| "timeout"
+7 -2
View File
@@ -44,7 +44,7 @@ const DEFAULT_PATH =
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
const _stringEnum = <T extends readonly string[]>(
const stringEnum = <T extends readonly string[]>(
values: T,
options?: { description?: string },
) =>
@@ -453,7 +453,12 @@ export function createBashTool(
export const bashTool = createBashTool();
const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
action: stringEnum(
["list", "poll", "log", "write", "kill", "clear", "remove"] as const,
{
description: "Process action",
},
),
sessionId: Type.Optional(
Type.String({ description: "Session id for actions other than list" }),
),
+3 -14
View File
@@ -9,7 +9,6 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
buildBootstrapContextFiles,
@@ -137,7 +136,6 @@ function buildSystemPrompt(params: {
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
ownerNumbers?: string[];
heartbeatPrompt?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
@@ -152,7 +150,9 @@ function buildSystemPrompt(params: {
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
@@ -374,23 +374,12 @@ export async function runClaudeCliAgent(params: {
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
: undefined;
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
tools: [],
contextFiles,
modelDisplay,
-17
View File
@@ -15,14 +15,6 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
});
it("infers format errors from error messages", () => {
expect(
resolveFailoverReasonFromError({
message: "invalid request format: messages.1.content.1.tool_use.id",
}),
).toBe("format");
});
it("infers timeout from common node error codes", () => {
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe(
"timeout",
@@ -44,15 +36,6 @@ describe("failover-error", () => {
expect(err?.model).toBe("claude-opus-4-5");
});
it("coerces format errors with a 400 status", () => {
const err = coerceToFailoverError("invalid request format", {
provider: "google",
model: "cloud-code-assist",
});
expect(err?.reason).toBe("format");
expect(err?.status).toBe(400);
});
it("describes non-Error values consistently", () => {
const described = describeFailoverError(123);
expect(described.message).toBe("123");
-2
View File
@@ -50,8 +50,6 @@ export function resolveFailoverStatus(
return 401;
case "timeout":
return 408;
case "format":
return 400;
default:
return undefined;
}
-196
View File
@@ -1,196 +0,0 @@
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../config/config.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const ALL_MODELS =
process.env.CLAWDBOT_LIVE_ALL_MODELS === "1" ||
process.env.CLAWDBOT_LIVE_MODELS === "all";
const REQUIRE_PROFILE_KEYS =
process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
const describeLive = LIVE && ALL_MODELS ? describe : describe.skip;
function parseModelFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") return null;
const ids = trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? new Set(ids) : null;
}
describeLive("live models (profile keys)", () => {
it(
"completes across configured models",
async () => {
const cfg = loadConfig();
await ensureClawdbotModelsJson(cfg);
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const models = modelRegistry.getAll() as Array<Model<Api>>;
const filter = parseModelFilter(process.env.CLAWDBOT_LIVE_MODELS);
const failures: Array<{ model: string; error: string }> = [];
const skipped: Array<{ model: string; reason: string }> = [];
for (const model of models) {
const id = `${model.provider}/${model.id}`;
if (filter && !filter.has(id)) continue;
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>>;
try {
apiKeyInfo = await getApiKeyForModel({ model, cfg });
} catch (err) {
skipped.push({ model: id, reason: String(err) });
continue;
}
if (REQUIRE_PROFILE_KEYS && !apiKeyInfo.source.startsWith("profile:")) {
skipped.push({
model: id,
reason: `non-profile credential source: ${apiKeyInfo.source}`,
});
continue;
}
try {
// Special regression: OpenAI rejects replayed `reasoning` items for tool-only turns.
if (
model.provider === "openai" &&
model.api === "openai-responses" &&
model.id === "gpt-5.2"
) {
const noopTool = {
name: "noop",
description: "Return ok.",
parameters: Type.Object({}, { additionalProperties: false }),
};
const first = await completeSimple(
model,
{
messages: [
{
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
],
tools: [noopTool],
},
{
apiKey: apiKeyInfo.apiKey,
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 128,
temperature: 0,
},
);
const toolCall = first.content.find((b) => b.type === "toolCall");
expect(toolCall).toBeTruthy();
if (!toolCall || toolCall.type !== "toolCall") {
throw new Error("expected tool call");
}
const second = await completeSimple(
model,
{
messages: [
{
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
first,
{
role: "toolResult",
toolCallId: toolCall.id,
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
},
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{
apiKey: apiKeyInfo.apiKey,
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 64,
temperature: 0,
},
);
const secondText = second.content
.filter((b) => b.type === "text")
.map((b) => b.text.trim())
.join(" ");
expect(secondText.length).toBeGreaterThan(0);
continue;
}
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{
apiKey: apiKeyInfo.apiKey,
reasoning: model.reasoning ? "low" : undefined,
maxTokens: 64,
temperature: 0,
},
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
} catch (err) {
failures.push({ model: id, error: String(err) });
}
}
if (failures.length > 0) {
const preview = failures
.slice(0, 10)
.map((f) => `- ${f.model}: ${f.error}`)
.join("\n");
throw new Error(
`live model failures (${failures.length}):\n${preview}`,
);
}
// Keep one assertion so the test fails loudly if we somehow ran nothing.
expect(models.length).toBeGreaterThan(0);
void skipped;
},
15 * 60 * 1000,
);
});
@@ -1,216 +0,0 @@
import type {
AssistantMessage,
Model,
ToolResultMessage,
} from "@mariozechner/pi-ai";
import { streamOpenAIResponses } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
function buildModel(): Model<"openai-responses"> {
return {
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
};
}
function installFailingFetchCapture() {
const originalFetch = globalThis.fetch;
let lastBody: unknown;
const fetchImpl: typeof fetch = async (_input, init) => {
const rawBody = init?.body;
const bodyText = (() => {
if (!rawBody) return "";
if (typeof rawBody === "string") return rawBody;
if (rawBody instanceof Uint8Array)
return Buffer.from(rawBody).toString("utf8");
if (rawBody instanceof ArrayBuffer)
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
return String(rawBody);
})();
lastBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined;
throw new Error("intentional fetch abort (test)");
};
globalThis.fetch = fetchImpl;
return {
getLastBody: () => lastBody as Record<string, unknown> | undefined,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}
describe("openai-responses reasoning replay", () => {
it("does not replay standalone reasoning for tool-call-only turns", async () => {
const cap = installFailingFetchCapture();
try {
const model = buildModel();
const assistantToolOnly: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
content: [
{
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
{
type: "toolCall",
id: "call_123|fc_123",
name: "noop",
arguments: {},
},
],
};
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{
role: "user",
content: "Call noop.",
timestamp: Date.now(),
},
assistantToolOnly,
toolResult,
{
role: "user",
content: "Now reply with ok.",
timestamp: Date.now(),
},
],
tools: [
{
name: "noop",
description: "no-op",
parameters: Type.Object({}, { additionalProperties: false }),
},
],
},
{ apiKey: "test" },
);
await stream.result();
const body = cap.getLastBody();
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object"
? (item as Record<string, unknown>).type
: undefined,
)
.filter((t): t is string => typeof t === "string");
expect(types).toContain("function_call");
expect(types).not.toContain("reasoning");
} finally {
cap.restore();
}
});
it("still replays reasoning when paired with an assistant message", async () => {
const cap = installFailingFetchCapture();
try {
const model = buildModel();
const assistantWithText: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
content: [
{
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
{ type: "text", text: "hello", textSignature: "msg_test" },
],
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
},
{ apiKey: "test" },
);
await stream.result();
const body = cap.getLastBody();
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object"
? (item as Record<string, unknown>).type
: undefined,
)
.filter((t): t is string => typeof t === "string");
expect(types).toContain("reasoning");
expect(types).toContain("message");
} finally {
cap.restore();
}
});
});
+1 -5
View File
@@ -249,7 +249,6 @@ describe("isFailoverErrorMessage", () => {
"429 rate limit exceeded",
"Your credit balance is too low",
"request timed out",
"invalid request format",
];
for (const sample of samples) {
expect(isFailoverErrorMessage(sample)).toBe(true);
@@ -264,12 +263,9 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit",
);
expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe(
"format",
);
expect(classifyFailoverReason("string should match pattern")).toBeNull();
expect(classifyFailoverReason("bad request")).toBeNull();
});
});
+54 -70
View File
@@ -276,84 +276,47 @@ export function isRateLimitAssistantError(
msg: AssistantMessage | undefined,
): boolean {
if (!msg || msg.stopReason !== "error") return false;
return isRateLimitErrorMessage(msg.errorMessage ?? "");
}
type ErrorPattern = RegExp | string;
const ERROR_PATTERNS = {
rateLimit: [
/rate[_ ]limit|too many requests|429/,
"exceeded your current quota",
"resource has been exhausted",
"quota exceeded",
"resource_exhausted",
],
timeout: [
"timeout",
"timed out",
"deadline exceeded",
"context deadline exceeded",
],
billing: [
/\b402\b/,
"payment required",
"insufficient credits",
"credit balance",
"plans & billing",
],
auth: [
/invalid[_ ]?api[_ ]?key/,
"incorrect api key",
"invalid token",
"authentication",
"unauthorized",
"forbidden",
"access denied",
"expired",
"token has expired",
/\b401\b/,
/\b403\b/,
],
format: [
"invalid_request_error",
"string should match pattern",
"tool_use.id",
"tool_use_id",
"messages.1.content.1.tool_use.id",
"invalid request format",
],
} as const;
function matchesErrorPatterns(
raw: string,
patterns: readonly ErrorPattern[],
): boolean {
const raw = (msg.errorMessage ?? "").toLowerCase();
if (!raw) return false;
const value = raw.toLowerCase();
return patterns.some((pattern) =>
pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern),
);
return isRateLimitErrorMessage(raw);
}
export function isRateLimitErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit);
const value = raw.toLowerCase();
return (
/rate[_ ]limit|too many requests|429/.test(value) ||
value.includes("exceeded your current quota") ||
value.includes("resource has been exhausted") ||
value.includes("quota exceeded") ||
value.includes("resource_exhausted")
);
}
export function isTimeoutErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout);
const value = raw.toLowerCase();
if (!value) return false;
return (
value.includes("timeout") ||
value.includes("timed out") ||
value.includes("deadline exceeded") ||
value.includes("context deadline exceeded")
);
}
export function isBillingErrorMessage(raw: string): boolean {
const value = raw.toLowerCase();
if (!value) return false;
if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) return true;
return (
value.includes("billing") &&
(value.includes("upgrade") ||
value.includes("credits") ||
value.includes("payment") ||
value.includes("plan"))
/\b402\b/.test(value) ||
value.includes("payment required") ||
value.includes("insufficient credits") ||
value.includes("credit balance") ||
value.includes("plans & billing") ||
(value.includes("billing") &&
(value.includes("upgrade") ||
value.includes("credits") ||
value.includes("payment") ||
value.includes("plan")))
);
}
@@ -365,11 +328,34 @@ export function isBillingAssistantError(
}
export function isAuthErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
const value = raw.toLowerCase();
if (!value) return false;
return (
/invalid[_ ]?api[_ ]?key/.test(value) ||
value.includes("incorrect api key") ||
value.includes("invalid token") ||
value.includes("authentication") ||
value.includes("unauthorized") ||
value.includes("forbidden") ||
value.includes("access denied") ||
value.includes("expired") ||
value.includes("token has expired") ||
/\b401\b/.test(value) ||
/\b403\b/.test(value)
);
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
const value = raw.toLowerCase();
if (!value) return false;
return (
value.includes("invalid_request_error") ||
value.includes("string should match pattern") ||
value.includes("tool_use.id") ||
value.includes("tool_use_id") ||
value.includes("messages.1.content.1.tool_use.id") ||
value.includes("invalid request format")
);
}
export function isAuthAssistantError(
@@ -381,18 +367,16 @@ export function isAuthAssistantError(
export type FailoverReason =
| "auth"
| "format"
| "rate_limit"
| "billing"
| "timeout"
| "unknown";
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isAuthErrorMessage(raw)) return "auth";
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";
if (isBillingErrorMessage(raw)) return "billing";
if (isTimeoutErrorMessage(raw)) return "timeout";
if (isAuthErrorMessage(raw)) return "auth";
return null;
}
+16 -63
View File
@@ -2,8 +2,6 @@ import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import {
applyGoogleTurnOrderingFix,
buildEmbeddedSandboxInfo,
@@ -59,54 +57,6 @@ describe("buildEmbeddedSandboxInfo", () => {
});
});
describe("resolveSessionAgentIds", () => {
const cfg = {
agents: {
list: [{ id: "main" }, { id: "beta", default: true }],
},
} as ClawdbotConfig;
it("falls back to the configured default when sessionKey is missing", () => {
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
config: cfg,
});
expect(defaultAgentId).toBe("beta");
expect(sessionAgentId).toBe("beta");
});
it("falls back to the configured default when sessionKey is non-agent", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "telegram:slash:123",
config: cfg,
});
expect(sessionAgentId).toBe("beta");
});
it("falls back to the configured default for global sessions", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "global",
config: cfg,
});
expect(sessionAgentId).toBe("beta");
});
it("keeps the agent id for provider-qualified agent sessions", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "agent:beta:slack:channel:C1",
config: cfg,
});
expect(sessionAgentId).toBe("beta");
});
it("uses the agent id from agent session keys", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "agent:main:main",
config: cfg,
});
expect(sessionAgentId).toBe("main");
});
});
function createStubTool(name: string): AgentTool {
return {
name,
@@ -118,11 +68,12 @@ function createStubTool(name: string): AgentTool {
}
describe("splitSdkTools", () => {
// Tool names are now capitalized (Bash, Read, etc.) to bypass Anthropic OAuth blocking
const tools = [
createStubTool("read"),
createStubTool("bash"),
createStubTool("edit"),
createStubTool("write"),
createStubTool("Read"),
createStubTool("Bash"),
createStubTool("Edit"),
createStubTool("Write"),
createStubTool("browser"),
];
@@ -133,25 +84,27 @@ describe("splitSdkTools", () => {
});
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
});
it("routes all tools to customTools even when not sandboxed", () => {
it("routes all tools to customTools even when not sandboxed (for OAuth compatibility)", () => {
// All tools are now passed as customTools to bypass pi-coding-agent's
// built-in tool filtering, which expects lowercase names.
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: false,
});
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
});
+23 -91
View File
@@ -17,7 +17,6 @@ import {
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
import type {
ReasoningLevel,
ThinkLevel,
@@ -29,6 +28,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import {
type enqueueCommand,
enqueueCommandInLane,
@@ -36,7 +36,6 @@ import {
import { normalizeMessageProvider } from "../utils/message-provider.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import {
markAuthProfileFailure,
markAuthProfileGood,
@@ -59,7 +58,6 @@ import {
ensureAuthProfileStore,
getApiKeyForModel,
resolveAuthProfileOrder,
resolveModelAuthMode,
} from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import {
@@ -606,8 +604,10 @@ export function createSystemPromptOverride(
return () => trimmed;
}
// We always pass tools via `customTools` so our policy filtering, sandbox integration,
// and extended toolset remain consistent across providers.
// Tool names are now capitalized (Bash, Read, Write, Edit) to bypass Anthropic's
// OAuth token blocking of lowercase names. However, pi-coding-agent's SDK has
// hardcoded lowercase names in its built-in tool registry, so we must pass ALL
// tools as customTools to bypass the SDK's filtering.
type AnyAgentTool = AgentTool;
@@ -618,8 +618,9 @@ export function splitSdkTools(options: {
builtInTools: AnyAgentTool[];
customTools: ReturnType<typeof toToolDefinitions>;
} {
// Always pass all tools as customTools so the SDK doesn't "helpfully" swap in
// its own built-in implementations (we need our tool wrappers + policy).
// Always pass all tools as customTools to bypass pi-coding-agent's built-in
// tool filtering, which expects lowercase names (bash, read, write, edit).
// Our tools are now capitalized (Bash, Read, Write, Edit) for OAuth compatibility.
const { tools } = options;
return {
builtInTools: [],
@@ -841,7 +842,6 @@ export async function compactEmbeddedPiSession(params: {
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const runAbortController = new AbortController();
const tools = createClawdbotCodingTools({
bash: {
...params.config?.tools?.bash,
@@ -853,9 +853,6 @@ export async function compactEmbeddedPiSession(params: {
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
// No currentChannelId/currentThreadTs for compaction - not in message context
});
const machineName = await getMachineDisplayName();
@@ -884,23 +881,15 @@ export async function compactEmbeddedPiSession(params: {
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
// Only include heartbeat prompt for the default agent
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
: undefined,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
),
skillsPrompt,
runtimeInfo,
sandboxInfo,
@@ -1028,7 +1017,6 @@ export async function runEmbeddedPiAgent(params: {
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
}) => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";
blockReplyChunking?: BlockReplyChunking;
@@ -1057,7 +1045,6 @@ export async function runEmbeddedPiAgent(params: {
const enqueueGlobal =
params.enqueue ??
((task, opts) => enqueueCommandInLane(globalLane, task, opts));
const runAbortController = new AbortController();
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
const started = Date.now();
@@ -1236,9 +1223,6 @@ export async function runEmbeddedPiAgent(params: {
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
@@ -1258,23 +1242,15 @@ export async function runEmbeddedPiAgent(params: {
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
// Only include heartbeat prompt for the default agent
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
: undefined,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
),
skillsPrompt,
runtimeInfo,
sandboxInfo,
@@ -1350,7 +1326,6 @@ export async function runEmbeddedPiAgent(params: {
const abortRun = (isTimeout = false) => {
aborted = true;
if (isTimeout) timedOut = true;
runAbortController.abort();
void session.abort();
};
let subscription: ReturnType<typeof subscribeEmbeddedPiSession>;
@@ -1558,7 +1533,9 @@ export async function runEmbeddedPiAgent(params: {
: false;
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
const shouldRotate = (!aborted && failoverFailure) || timedOut;
const shouldRotate =
(!aborted && (failoverFailure || cloudCodeAssistFormatError)) ||
timedOut;
if (shouldRotate) {
// Mark current profile for cooldown before rotating
@@ -1627,10 +1604,6 @@ export async function runEmbeddedPiAgent(params: {
text: string;
media?: string[];
isError?: boolean;
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
}> = [];
const errorText = lastAssistant
@@ -1647,23 +1620,10 @@ export async function runEmbeddedPiAgent(params: {
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(agg);
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({
text: cleanedText,
media: mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
@@ -1682,46 +1642,18 @@ export async function runEmbeddedPiAgent(params: {
? [fallbackAnswerText]
: [];
for (const text of answerTexts) {
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(text);
if (
!cleanedText &&
(!mediaUrls || mediaUrls.length === 0) &&
!audioAsVoice
)
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
continue;
replyItems.push({
text: cleanedText,
media: mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
replyItems.push({ text: cleanedText, media: mediaUrls });
}
// Check if any replyItem has audioAsVoice tag - if so, apply to all media payloads
const hasAudioAsVoiceTag = replyItems.some(
(item) => item.audioAsVoice,
);
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
// Apply audioAsVoice to media payloads if tag was found anywhere in response
audioAsVoice:
item.audioAsVoice || (hasAudioAsVoiceTag && item.media?.length),
}))
.filter(
(p) =>
+8 -22
View File
@@ -3,12 +3,12 @@ import path from "node:path";
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { resolveStateDir } from "../config/paths.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { truncateUtf16Safe } from "../utils.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
@@ -262,7 +262,6 @@ export function subscribeEmbeddedPiSession(params: {
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
}) => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";
blockReplyChunking?: BlockReplyChunking;
@@ -383,7 +382,7 @@ export function subscribeEmbeddedPiSession(params: {
const emitToolSummary = (toolName?: string, meta?: string) => {
if (!params.onToolResult) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
try {
void params.onToolResult({
@@ -437,15 +436,11 @@ export function subscribeEmbeddedPiSession(params: {
lastBlockReplyText = chunk;
assistantTexts.push(chunk);
if (!params.onBlockReply) return;
const splitResult = parseReplyDirectives(chunk);
const { text: cleanedText, mediaUrls, audioAsVoice } = splitResult;
// Skip empty payloads, but always emit if audioAsVoice is set (to propagate the flag)
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice)
return;
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(chunk);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
void params.onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
});
};
@@ -739,7 +734,7 @@ export function subscribeEmbeddedPiSession(params: {
if (next && next !== lastStreamedAssistant) {
lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } =
parseReplyDirectives(next);
splitMediaFromOutput(next);
emitAgentEvent({
runId: params.runId,
stream: "assistant",
@@ -864,21 +859,12 @@ export function subscribeEmbeddedPiSession(params: {
);
} else {
lastBlockReplyText = text;
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
} = parseReplyDirectives(text);
// Emit if there's content OR audioAsVoice flag (to propagate the flag)
if (
cleanedText ||
(mediaUrls && mediaUrls.length > 0) ||
audioAsVoice
) {
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(text);
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
});
}
}
+22 -22
View File
@@ -28,9 +28,9 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).toContain("write");
expect(toolNames).not.toContain("bash");
expect(toolNames).toContain("Read");
expect(toolNames).toContain("Write");
expect(toolNames).not.toContain("Bash");
});
it("should keep global tool policy when agent only sets tools.elevated", () => {
@@ -62,9 +62,9 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("bash");
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("write");
expect(toolNames).toContain("Bash");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Write");
});
it("should apply agent-specific tool policy", () => {
@@ -95,10 +95,10 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("edit");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
expect(toolNames).not.toContain("Edit");
});
it("should allow different tool policies for different agents", () => {
@@ -130,9 +130,9 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-main",
});
const mainToolNames = mainTools.map((t) => t.name);
expect(mainToolNames).toContain("bash");
expect(mainToolNames).toContain("write");
expect(mainToolNames).toContain("edit");
expect(mainToolNames).toContain("Bash");
expect(mainToolNames).toContain("Write");
expect(mainToolNames).toContain("Edit");
// family agent: restricted
const familyTools = createClawdbotCodingTools({
@@ -142,10 +142,10 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-family",
});
const familyToolNames = familyTools.map((t) => t.name);
expect(familyToolNames).toContain("read");
expect(familyToolNames).not.toContain("bash");
expect(familyToolNames).not.toContain("write");
expect(familyToolNames).not.toContain("edit");
expect(familyToolNames).toContain("Read");
expect(familyToolNames).not.toContain("Bash");
expect(familyToolNames).not.toContain("Write");
expect(familyToolNames).not.toContain("Edit");
});
it("should prefer agent-specific tool policy over global", () => {
@@ -176,7 +176,7 @@ describe("Agent-specific tool filtering", () => {
const toolNames = tools.map((t) => t.name);
// Agent policy overrides global: browser is allowed again
expect(toolNames).toContain("browser");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("process");
});
@@ -247,9 +247,9 @@ describe("Agent-specific tool filtering", () => {
// Agent policy should be applied first, then sandbox
// Agent allows only "read", sandbox allows ["read", "write", "bash"]
// Result: only "read" (most restrictive wins)
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
});
it("should run bash synchronously when process is denied", async () => {
@@ -265,7 +265,7 @@ describe("Agent-specific tool filtering", () => {
workspaceDir: "/tmp/test-main",
agentDir: "/tmp/agent-main",
});
const bash = tools.find((tool) => tool.name === "bash");
const bash = tools.find((tool) => tool.name === "Bash");
expect(bash).toBeDefined();
const result = await bash?.execute("call1", {
+44 -31
View File
@@ -31,19 +31,35 @@ describe("createClawdbotCodingTools", () => {
expect(parameters.required ?? []).toContain("action");
});
it("exposes raw for gateway config.apply tool calls", () => {
it("requires raw for gateway config.apply tool calls", () => {
const tools = createClawdbotCodingTools();
const gateway = tools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined();
const parameters = gateway?.parameters as {
type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
allOf?: Array<Record<string, unknown>>;
};
expect(parameters.type).toBe("object");
expect(parameters.properties?.raw).toBeDefined();
expect(parameters.required ?? []).not.toContain("raw");
const conditional = parameters.allOf?.find(
(entry) => "if" in entry && "then" in entry,
) as
| { if?: Record<string, unknown>; then?: Record<string, unknown> }
| undefined;
expect(conditional).toBeDefined();
const thenRequired = conditional?.then?.required as string[] | undefined;
expect(thenRequired ?? []).toContain("raw");
const action = (
conditional?.if?.properties as Record<string, unknown> | undefined
)?.action as { const?: unknown; enum?: unknown[] } | undefined;
const values = new Set<string>();
if (typeof action?.const === "string") values.add(action.const);
if (Array.isArray(action?.enum)) {
for (const value of action.enum) {
if (typeof value === "string") values.add(value);
}
}
expect(values.has("config.apply")).toBe(true);
});
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
@@ -154,22 +170,11 @@ describe("createClawdbotCodingTools", () => {
it("includes bash and process tools", () => {
const tools = createClawdbotCodingTools();
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "process")).toBe(true);
});
it("keeps canonical tool names for Anthropic OAuth (pi-ai remaps on the wire)", () => {
const tools = createClawdbotCodingTools({
modelProvider: "anthropic",
modelAuthMode: "oauth",
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("bash")).toBe(true);
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
});
it("provides top-level object schemas for all tools", () => {
const tools = createClawdbotCodingTools();
const offenders = tools
@@ -208,8 +213,9 @@ describe("createClawdbotCodingTools", () => {
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("read")).toBe(true);
expect(names.has("bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(names.has("Read")).toBe(true);
expect(names.has("Bash")).toBe(true);
expect(names.has("process")).toBe(true);
});
@@ -228,12 +234,14 @@ describe("createClawdbotCodingTools", () => {
},
},
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
// Tool names are capitalized for OAuth compatibility
expect(tools.map((tool) => tool.name)).toEqual(["Read"]);
});
it("keeps read tool image metadata intact", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -273,7 +281,8 @@ describe("createClawdbotCodingTools", () => {
it("returns text content without image blocks for text files", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -328,8 +337,10 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
// NOTE: bash/read are capitalized to bypass Anthropic OAuth blocking
// Policy matching is case-insensitive, so allow: ["bash"] matches tool named "Bash"
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "Read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
@@ -359,16 +370,18 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
// NOTE: read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Read")).toBe(true);
expect(tools.some((tool) => tool.name === "Write")).toBe(false);
expect(tools.some((tool) => tool.name === "Edit")).toBe(false);
});
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["browser"] } },
});
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
+25 -63
View File
@@ -23,7 +23,6 @@ import {
type ProcessToolDefaults,
} from "./bash-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import type { ModelAuthMode } from "./model-auth.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
@@ -284,6 +283,28 @@ function normalizeToolNames(list?: string[]) {
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
/**
* Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
* Renaming to capitalized versions bypasses the block while maintaining compatibility
* with regular API keys.
*/
const OAUTH_BLOCKED_TOOL_NAMES: Record<string, string> = {
bash: "Bash",
read: "Read",
write: "Write",
edit: "Edit",
};
function renameBlockedToolsForOAuth(tools: AnyAgentTool[]): AnyAgentTool[] {
return tools.map((tool) => {
const newName = OAUTH_BLOCKED_TOOL_NAMES[tool.name];
if (newName) {
return { ...tool, name: newName };
}
return tool;
});
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
"sessions_list",
"sessions_history",
@@ -482,48 +503,6 @@ export const __testing = {
cleanToolSchemaForGemini,
} as const;
function throwAbortError(): never {
const err = new Error("Aborted");
err.name = "AbortError";
throw err;
}
function combineAbortSignals(
a?: AbortSignal,
b?: AbortSignal,
): AbortSignal | undefined {
if (!a && !b) return undefined;
if (a && !b) return a;
if (b && !a) return b;
if (a?.aborted) return a;
if (b?.aborted) return b;
if (typeof AbortSignal.any === "function") {
return AbortSignal.any([a as AbortSignal, b as AbortSignal]);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
a?.addEventListener("abort", onAbort, { once: true });
b?.addEventListener("abort", onAbort, { once: true });
return controller.signal;
}
function wrapToolWithAbortSignal(
tool: AnyAgentTool,
abortSignal?: AbortSignal,
): AnyAgentTool {
if (!abortSignal) return tool;
const execute = tool.execute;
if (!execute) return tool;
return {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const combined = combineAbortSignals(signal, abortSignal);
if (combined?.aborted) throwAbortError();
return await execute(toolCallId, params, combined, onUpdate);
},
};
}
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
@@ -532,17 +511,6 @@ export function createClawdbotCodingTools(options?: {
sessionKey?: string;
agentDir?: string;
config?: ClawdbotConfig;
abortSignal?: AbortSignal;
/**
* Provider of the currently selected model (used for provider-specific tool quirks).
* Example: "anthropic", "openai", "google", "openai-codex".
*/
modelProvider?: string;
/**
* Auth mode for the current provider. We only need this for Anthropic OAuth
* tool-name blocking quirks.
*/
modelAuthMode?: ModelAuthMode;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
@@ -639,14 +607,8 @@ export function createClawdbotCodingTools(options?: {
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
const normalized = subagentFiltered.map(normalizeToolParameters);
const withAbort = options?.abortSignal
? normalized.map((tool) =>
wrapToolWithAbortSignal(tool, options.abortSignal),
)
: normalized;
// NOTE: Keep canonical (lowercase) tool names here.
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names
// on the wire and maps them back for tool dispatch.
return withAbort;
// Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
// Always use capitalized versions for compatibility with both OAuth and regular API keys.
return renameBlockedToolsForOAuth(normalized);
}
-51
View File
@@ -55,8 +55,6 @@ export type SandboxBrowserConfig = {
noVncPort: number;
headless: boolean;
enableNoVnc: boolean;
autoStart: boolean;
autoStartTimeoutMs: number;
};
export type SandboxDockerConfig = {
@@ -161,7 +159,6 @@ const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdbot-sbx-browser-";
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
@@ -310,38 +307,9 @@ export function resolveSandboxBrowserConfig(params: {
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc:
agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs:
agentBrowser?.autoStartTimeoutMs ??
globalBrowser?.autoStartTimeoutMs ??
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
};
}
async function waitForSandboxCdp(params: {
cdpPort: number;
timeoutMs: number;
}): Promise<boolean> {
const deadline = Date.now() + Math.max(0, params.timeoutMs);
const url = `http://127.0.0.1:${params.cdpPort}/json/version`;
while (Date.now() < deadline) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1000);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (res.ok) return true;
} finally {
clearTimeout(t);
}
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, 150));
}
return false;
}
export function resolveSandboxPruneConfig(params: {
scope: SandboxScope;
globalPrune?: Partial<SandboxPruneConfig>;
@@ -953,31 +921,12 @@ async function ensureSandboxBrowser(params: {
if (shouldReuse && existing) {
bridge = existing.bridge;
} else {
const onEnsureAttachTarget = params.cfg.browser.autoStart
? async () => {
const state = await dockerContainerState(containerName);
if (state.exists && !state.running) {
await execDocker(["start", containerName]);
}
const ok = await waitForSandboxCdp({
cdpPort: mappedCdp,
timeoutMs: params.cfg.browser.autoStartTimeoutMs,
});
if (!ok) {
throw new Error(
`Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`,
);
}
}
: undefined;
bridge = await startBrowserBridgeServer({
resolved: buildSandboxBrowserResolvedConfig({
controlPort: 0,
cdpPort: mappedCdp,
headless: params.cfg.browser.headless,
}),
onEnsureAttachTarget,
});
}
if (!shouldReuse) {
-15
View File
@@ -46,21 +46,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("sessions_send");
});
it("preserves tool casing in the prompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
toolNames: ["Read", "Bash", "process"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
});
expect(prompt).toContain("- Read: Read file contents");
expect(prompt).toContain("- Bash: Run shell commands");
expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.",
);
});
it("includes user time when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
+8 -23
View File
@@ -84,19 +84,9 @@ export function buildAgentSystemPrompt(params: {
"image",
];
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean);
const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) {
const normalized = name.toLowerCase();
if (!canonicalByNormalized.has(normalized)) {
canonicalByNormalized.set(normalized, name);
}
}
const resolveToolName = (normalized: string) =>
canonicalByNormalized.get(normalized) ?? normalized;
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
const normalizedTools = (params.toolNames ?? [])
.map((tool) => tool.trim().toLowerCase())
.filter(Boolean);
const availableTools = new Set(normalizedTools);
const extraTools = Array.from(
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
@@ -104,17 +94,13 @@ export function buildAgentSystemPrompt(params: {
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = toolSummaries[tool];
const name = resolveToolName(tool);
return summary ? `- ${name}: ${summary}` : `- ${name}`;
return summary ? `- ${tool}: ${summary}` : `- ${tool}`;
});
for (const tool of extraTools.sort()) {
toolLines.push(`- ${resolveToolName(tool)}`);
toolLines.push(`- ${tool}`);
}
const hasGateway = availableTools.has("gateway");
const readToolName = resolveToolName("read");
const bashToolName = resolveToolName("bash");
const processToolName = resolveToolName("process");
const extraSystemPrompt = params.extraSystemPrompt?.trim();
const ownerNumbers = (params.ownerNumbers ?? [])
.map((value) => value.trim())
@@ -157,7 +143,7 @@ export function buildAgentSystemPrompt(params: {
const skillsSection = skillsPrompt
? [
"## Skills",
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
"Skills provide task-specific instructions. Use `read` to load the SKILL.md at the location listed for that skill.",
...skillsLines,
"",
]
@@ -168,7 +154,6 @@ export function buildAgentSystemPrompt(params: {
"",
"## Tooling",
"Tool availability (filtered by policy):",
"Tool names are case-sensitive. Call tools exactly as listed.",
toolLines.length > 0
? toolLines.join("\n")
: [
@@ -176,8 +161,8 @@ export function buildAgentSystemPrompt(params: {
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
`- ${bashToolName}: run shell commands (supports background via yieldMs/background)`,
`- ${processToolName}: manage background bash sessions`,
"- bash: run shell commands (supports background via yieldMs/background)",
"- process: manage background bash sessions",
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
+14 -4
View File
@@ -37,10 +37,20 @@ const GatewayToolSchema = Type.Object({
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()),
});
// NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here:
// - OpenAI rejects tool schemas that include these keywords at the *top-level*.
// - Claude/Vertex has other JSON Schema quirks.
// Conditional requirements (like `raw` for config.apply) are enforced at runtime.
// Keep top-level object schemas while enforcing conditional requirements.
(GatewayToolSchema as typeof GatewayToolSchema & { allOf?: unknown[] }).allOf =
[
{
if: {
properties: {
action: { const: "config.apply" },
},
required: ["action"],
},
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword.
then: { required: ["raw"] },
},
];
export function createGatewayTool(opts?: {
agentSessionKey?: string;
-7
View File
@@ -136,13 +136,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "config",
nativeName: "config",
description: "Show or set config values.",
textAlias: "/config",
acceptsArgs: true,
}),
defineChatCommand({
key: "debug",
nativeName: "debug",
+2 -5
View File
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url";
import {
resolveAgentConfig,
resolveAgentDir,
resolveAgentIdFromSessionKey,
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../agents/agent-scope.js";
import { resolveModelRefFromString } from "../agents/model-selection.js";
import {
@@ -250,10 +250,7 @@ export async function getReplyFromConfig(
configOverride?: ClawdbotConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig();
const agentId = resolveSessionAgentId({
sessionKey: ctx.SessionKey,
config: cfg,
});
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
-98
View File
@@ -1,21 +1,3 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import {
normalizeCommandBody,
shouldHandleTextCommands,
} from "../commands-registry.js";
import type { MsgContext } from "../templating.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>();
@@ -32,83 +14,3 @@ export function getAbortMemory(key: string): boolean | undefined {
export function setAbortMemory(key: string, value: boolean): void {
ABORT_MEMORY.set(key, value);
}
function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
sessionKey: string | undefined,
) {
if (!store || !sessionKey) return {};
const direct = store[sessionKey];
if (direct) return { entry: direct, key: sessionKey };
const parsed = parseAgentSessionKey(sessionKey);
const legacyKey = parsed?.rest;
if (legacyKey && store[legacyKey]) {
return { entry: store[legacyKey], key: legacyKey };
}
return {};
}
function resolveAbortTargetKey(ctx: MsgContext): string | undefined {
const target = ctx.CommandTargetSessionKey?.trim();
if (target) return target;
const sessionKey = ctx.SessionKey?.trim();
return sessionKey || undefined;
}
export async function tryFastAbortFromMessage(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
}): Promise<{ handled: boolean; aborted: boolean }> {
const { ctx, cfg } = params;
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface,
commandSource: ctx.CommandSource,
});
if (!allowTextCommands) return { handled: false, aborted: false };
const commandAuthorized = ctx.CommandAuthorized ?? true;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized,
});
if (!auth.isAuthorizedSender) return { handled: false, aborted: false };
const targetKey = resolveAbortTargetKey(ctx);
const agentId = resolveSessionAgentId({
sessionKey: targetKey ?? ctx.SessionKey ?? "",
config: cfg,
});
const raw = stripStructuralPrefixes(ctx.Body ?? "");
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
const normalized = normalizeCommandBody(stripped);
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
if (!abortRequested) return { handled: false, aborted: false };
const abortKey = targetKey ?? auth.from ?? auth.to;
if (targetKey) {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const { entry, key } = resolveSessionEntryForKey(store, targetKey);
const sessionId = entry?.sessionId;
const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
if (entry && key) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[key] = entry;
await saveSessionStore(storePath, store);
} else if (abortKey) {
setAbortMemory(abortKey, true);
}
return { handled: true, aborted };
}
if (abortKey) {
setAbortMemory(abortKey, true);
}
return { handled: true, aborted: false };
}
+16 -62
View File
@@ -22,7 +22,6 @@ import {
emitAgentEvent,
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { isAudioFileName } from "../../media/mime.js";
import { defaultRuntime } from "../../runtime.js";
import {
estimateUsageCost,
@@ -33,12 +32,10 @@ import {
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
createAudioAsVoiceBuffer,
createBlockReplyPipeline,
} from "./block-reply-pipeline.js";
import { extractAudioTag } from "./audio-tags.js";
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import {
@@ -47,7 +44,6 @@ import {
type QueueSettings,
scheduleFollowupDrain,
} from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyTagsToPayload,
applyReplyThreading,
@@ -264,13 +260,6 @@ export async function runReplyAgent(params: {
const pendingToolTasks = new Set<Promise<void>>();
const blockReplyTimeoutMs =
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
const hasAudioMedia = (urls?: string[]): boolean =>
Boolean(urls?.some((u) => isAudioFileName(u)));
const isAudioPayload = (payload: ReplyPayload) =>
hasAudioMedia(
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
);
const replyToChannel =
sessionCtx.OriginatingChannel ??
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
@@ -300,7 +289,6 @@ export async function runReplyAgent(params: {
onBlockReply: opts.onBlockReply,
timeoutMs: blockReplyTimeoutMs,
coalescing: blockReplyCoalescing,
buffer: createAudioAsVoiceBuffer({ isAudioPayload }),
})
: null;
@@ -544,53 +532,30 @@ export async function runReplyAgent(params: {
},
sessionCtx.MessageSid,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (
!isRenderablePayload(taggedPayload) &&
!payload.audioAsVoice
)
return;
const parsed = parseReplyDirectives(
taggedPayload.text ?? "",
{
currentMessageId: sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
},
);
const cleaned = parsed.text || undefined;
if (!isRenderablePayload(taggedPayload)) return;
const audioTagResult = extractAudioTag(taggedPayload.text);
const cleaned = audioTagResult.cleaned || undefined;
const hasMedia =
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (!cleaned && !hasMedia) return;
if (
!cleaned &&
!hasMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
!hasMedia
)
return;
if (parsed.isSilent && !hasMedia) return;
const blockPayload: ReplyPayload = applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(
parsed.audioAsVoice || payload.audioAsVoice,
),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent:
taggedPayload.replyToCurrent || parsed.replyToCurrent,
audioAsVoice: audioTagResult.audioAsVoice,
});
void typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.signalTextDelta(taggedPayload.text)
.catch((err) => {
logVerbose(
`block reply typing signal failed: ${String(err)}`,
);
});
blockReplyPipeline?.enqueue(blockPayload);
}
: undefined,
@@ -705,7 +670,6 @@ export async function runReplyAgent(params: {
}
const payloadArray = runResult.payloads ?? [];
if (blockReplyPipeline) {
await blockReplyPipeline.flush({ force: true });
blockReplyPipeline.stop();
@@ -713,7 +677,6 @@ export async function runReplyAgent(params: {
if (pendingToolTasks.size > 0) {
await Promise.allSettled(pendingToolTasks);
}
// Drain any late tool/block deliveries before deciding there's "nothing to send".
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
// keep the typing indicator stuck.
@@ -748,21 +711,11 @@ export async function runReplyAgent(params: {
currentMessageId: sessionCtx.MessageSid,
})
.map((payload) => {
const parsed = parseReplyDirectives(payload.text ?? "", {
currentMessageId: sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls;
const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0];
const audioTagResult = extractAudioTag(payload.text);
return {
...payload,
text: parsed.text ? parsed.text : undefined,
mediaUrls,
mediaUrl,
replyToId: payload.replyToId ?? parsed.replyToId,
replyToTag: payload.replyToTag || parsed.replyToTag,
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
text: audioTagResult.cleaned ? audioTagResult.cleaned : undefined,
audioAsVoice: audioTagResult.audioAsVoice,
};
})
.filter(isRenderablePayload);
@@ -798,7 +751,8 @@ export async function runReplyAgent(params: {
const shouldSignalTyping = replyPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed) return true;
if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
return true;
if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;
+10 -10
View File
@@ -1,25 +1,25 @@
import { describe, expect, it } from "vitest";
import { parseAudioTag } from "./audio-tags.js";
import { extractAudioTag } from "./audio-tags.js";
describe("parseAudioTag", () => {
describe("extractAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = parseAudioTag("Hello [[audio_as_voice]] world");
const result = extractAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hadTag).toBe(true);
expect(result.text).toBe("Hello world");
expect(result.hasTag).toBe(true);
expect(result.cleaned).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = parseAudioTag(undefined);
const result = extractAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hadTag).toBe(false);
expect(result.text).toBe("");
expect(result.hasTag).toBe(false);
expect(result.cleaned).toBe("");
});
it("removes tag-only messages", () => {
const result = parseAudioTag("[[audio_as_voice]]");
const result = extractAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("");
expect(result.cleaned).toBe("");
});
});
+31 -1
View File
@@ -1 +1,31 @@
export { parseAudioTag } from "../../media/audio-tags.js";
/**
* Extract audio mode tag from text.
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
* Default is file (preserves backward compatibility).
*/
export function extractAudioTag(text?: string): {
cleaned: string;
audioAsVoice: boolean;
hasTag: boolean;
} {
if (!text) return { cleaned: "", audioAsVoice: false, hasTag: false };
let cleaned = text;
let audioAsVoice = false; // default: audio file (backward compatible)
let hasTag = false;
// [[audio_as_voice]] -> send as voice bubble (opt-in)
const voiceMatch = cleaned.match(/\[\[audio_as_voice\]\]/i);
if (voiceMatch) {
cleaned = cleaned.replace(/\[\[audio_as_voice\]\]/gi, " ");
audioAsVoice = true;
hasTag = true;
}
// Clean up whitespace
cleaned = cleaned
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
return { cleaned, audioAsVoice, hasTag };
}
+2 -58
View File
@@ -13,28 +13,6 @@ export type BlockReplyPipeline = {
hasSentPayload: (payload: ReplyPayload) => boolean;
};
export type BlockReplyBuffer = {
shouldBuffer: (payload: ReplyPayload) => boolean;
onEnqueue?: (payload: ReplyPayload) => void;
finalize?: (payload: ReplyPayload) => ReplyPayload;
};
export function createAudioAsVoiceBuffer(params: {
isAudioPayload: (payload: ReplyPayload) => boolean;
}): BlockReplyBuffer {
let seenAudioAsVoice = false;
return {
onEnqueue: (payload) => {
if (payload.audioAsVoice) {
seenAudioAsVoice = true;
}
},
shouldBuffer: (payload) => params.isAudioPayload(payload),
finalize: (payload) =>
seenAudioAsVoice ? { ...payload, audioAsVoice: true } : payload,
};
}
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
const text = payload.text?.trim() ?? "";
const mediaList = payload.mediaUrls?.length
@@ -73,15 +51,12 @@ export function createBlockReplyPipeline(params: {
) => Promise<void> | void;
timeoutMs: number;
coalescing?: BlockStreamingCoalescing;
buffer?: BlockReplyBuffer;
}): BlockReplyPipeline {
const { onBlockReply, timeoutMs, coalescing, buffer } = params;
const { onBlockReply, timeoutMs, coalescing } = params;
const sentKeys = new Set<string>();
const pendingKeys = new Set<string>();
const seenKeys = new Set<string>();
const bufferedKeys = new Set<string>();
const bufferedPayloadKeys = new Set<string>();
const bufferedPayloads: ReplyPayload[] = [];
let sendChain: Promise<void> = Promise.resolve();
let aborted = false;
let didStream = false;
@@ -149,37 +124,8 @@ export function createBlockReplyPipeline(params: {
})
: null;
const bufferPayload = (payload: ReplyPayload) => {
buffer?.onEnqueue?.(payload);
if (!buffer?.shouldBuffer(payload)) return false;
const payloadKey = createBlockReplyPayloadKey(payload);
if (
seenKeys.has(payloadKey) ||
sentKeys.has(payloadKey) ||
pendingKeys.has(payloadKey) ||
bufferedPayloadKeys.has(payloadKey)
) {
return true;
}
seenKeys.add(payloadKey);
bufferedPayloadKeys.add(payloadKey);
bufferedPayloads.push(payload);
return true;
};
const flushBuffered = () => {
if (!bufferedPayloads.length) return;
for (const payload of bufferedPayloads) {
const finalPayload = buffer?.finalize?.(payload) ?? payload;
sendPayload(finalPayload, true);
}
bufferedPayloads.length = 0;
bufferedPayloadKeys.clear();
};
const enqueue = (payload: ReplyPayload) => {
if (aborted) return;
if (bufferPayload(payload)) return;
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (hasMedia) {
@@ -205,7 +151,6 @@ export function createBlockReplyPipeline(params: {
const flush = async (options?: { force?: boolean }) => {
await coalescer?.flush(options);
flushBuffered();
await sendChain;
};
@@ -217,8 +162,7 @@ export function createBlockReplyPipeline(params: {
enqueue,
flush,
stop,
hasBuffered: () =>
Boolean(coalescer?.hasBuffered() || bufferedPayloads.length > 0),
hasBuffered: () => Boolean(coalescer?.hasBuffered()),
didStream: () => didStream,
isAborted: () => aborted,
hasSentPayload: (payload) => {
+3 -141
View File
@@ -1,7 +1,6 @@
import {
resolveAgentDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import {
ensureAuthProfileStore,
@@ -20,17 +19,6 @@ import {
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../../config/config.js";
import {
getConfigValueAtPath,
parseConfigPath,
setConfigValueAtPath,
unsetConfigValueAtPath,
} from "../../config/config-paths.js";
import {
getConfigOverrides,
resetConfigOverrides,
@@ -38,6 +26,7 @@ import {
unsetConfigOverride,
} from "../../config/runtime-overrides.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
@@ -83,7 +72,6 @@ import type {
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js";
import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
@@ -160,7 +148,7 @@ export async function buildStatusReply(params: {
return undefined;
}
const statusAgentId = sessionKey
? resolveSessionAgentId({ sessionKey, config: cfg })
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
let usageLine: string | null = null;
@@ -640,130 +628,6 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply };
}
const configCommand = allowTextCommands
? parseConfigCommand(command.commandBodyNormalized)
: null;
if (configCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /config from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (configCommand.action === "error") {
return {
shouldContinue: false,
reply: { text: `⚠️ ${configCommand.message}` },
};
}
const snapshot = await readConfigFileSnapshot();
if (
!snapshot.valid ||
!snapshot.parsed ||
typeof snapshot.parsed !== "object"
) {
return {
shouldContinue: false,
reply: {
text: "⚠️ Config file is invalid; fix it before using /config.",
},
};
}
const parsedBase = structuredClone(
snapshot.parsed as Record<string, unknown>,
);
if (configCommand.action === "show") {
const pathRaw = configCommand.path?.trim();
if (pathRaw) {
const parsedPath = parseConfigPath(pathRaw);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
const value = getConfigValueAtPath(parsedBase, parsedPath.path);
const rendered = JSON.stringify(value ?? null, null, 2);
return {
shouldContinue: false,
reply: {
text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``,
},
};
}
const json = JSON.stringify(parsedBase, null, 2);
return {
shouldContinue: false,
reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` },
};
}
if (configCommand.action === "unset") {
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
if (!removed) {
return {
shouldContinue: false,
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
};
}
const validated = validateConfigObject(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];
return {
shouldContinue: false,
reply: {
text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`,
},
};
}
await writeConfigFile(validated.config);
return {
shouldContinue: false,
reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` },
};
}
if (configCommand.action === "set") {
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
const validated = validateConfigObject(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];
return {
shouldContinue: false,
reply: {
text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`,
},
};
}
await writeConfigFile(validated.config);
const valueLabel =
typeof configCommand.value === "string"
? `"${configCommand.value}"`
: JSON.stringify(configCommand.value);
return {
shouldContinue: false,
reply: {
text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`,
},
};
}
}
const debugCommand = allowTextCommands
? parseDebugCommand(command.commandBodyNormalized)
: null;
@@ -789,13 +653,11 @@ export async function handleCommands(params: {
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
const effectiveConfig = cfg ?? {};
const json = JSON.stringify(overrides, null, 2);
const effectiveJson = JSON.stringify(effectiveConfig, null, 2);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`\n⚙️ Effective config (with overrides):\n\`\`\`json\n${effectiveJson}\n\`\`\``,
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
},
};
}
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseConfigCommand } from "./config-commands.js";
describe("parseConfigCommand", () => {
it("parses show/unset", () => {
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
expect(parseConfigCommand("/config show")).toEqual({
action: "show",
path: undefined,
});
expect(parseConfigCommand("/config show foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config get foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
action: "unset",
path: "foo.bar",
});
});
it("parses set with JSON", () => {
const cmd = parseConfigCommand('/config set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
});
-64
View File
@@ -1,64 +0,0 @@
import { parseConfigValue } from "./config-value.js";
export type ConfigCommand =
| { action: "show"; path?: string }
| { action: "set"; path: string; value: unknown }
| { action: "unset"; path: string }
| { action: "error"; message: string };
export function parseConfigCommand(raw: string): ConfigCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/config")) return null;
const rest = trimmed.slice("/config".length).trim();
if (!rest) return { action: "show" };
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
if (!match) return { action: "error", message: "Invalid /config syntax." };
const action = match[1].toLowerCase();
const args = (match[2] ?? "").trim();
switch (action) {
case "show":
return { action: "show", path: args || undefined };
case "get":
return { action: "show", path: args || undefined };
case "unset": {
if (!args)
return { action: "error", message: "Usage: /config unset path" };
return { action: "unset", path: args };
}
case "set": {
if (!args) {
return {
action: "error",
message: "Usage: /config set path=value",
};
}
const eqIndex = args.indexOf("=");
if (eqIndex <= 0) {
return {
action: "error",
message: "Usage: /config set path=value",
};
}
const path = args.slice(0, eqIndex).trim();
const rawValue = args.slice(eqIndex + 1);
if (!path) {
return {
action: "error",
message: "Usage: /config set path=value",
};
}
const parsed = parseConfigValue(rawValue);
if (parsed.error) {
return { action: "error", message: parsed.error };
}
return { action: "set", path, value: parsed.value };
}
default:
return {
action: "error",
message: "Usage: /config show|set|unset",
};
}
}
-38
View File
@@ -1,38 +0,0 @@
export function parseConfigValue(raw: string): {
value?: unknown;
error?: string;
} {
const trimmed = raw.trim();
if (!trimmed) return { error: "Missing value." };
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return { value: JSON.parse(trimmed) };
} catch (err) {
return { error: `Invalid JSON: ${String(err)}` };
}
}
if (trimmed === "true") return { value: true };
if (trimmed === "false") return { value: false };
if (trimmed === "null") return { value: null };
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) return { value: num };
}
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
try {
return { value: JSON.parse(trimmed) };
} catch {
const unquoted = trimmed.slice(1, -1);
return { value: unquoted };
}
}
return { value: trimmed };
}
+37 -3
View File
@@ -1,5 +1,3 @@
import { parseConfigValue } from "./config-value.js";
export type DebugCommand =
| { action: "show" }
| { action: "reset" }
@@ -7,6 +5,42 @@ export type DebugCommand =
| { action: "unset"; path: string }
| { action: "error"; message: string };
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
const trimmed = raw.trim();
if (!trimmed) return { error: "Missing value." };
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return { value: JSON.parse(trimmed) };
} catch (err) {
return { error: `Invalid JSON: ${String(err)}` };
}
}
if (trimmed === "true") return { value: true };
if (trimmed === "false") return { value: false };
if (trimmed === "null") return { value: null };
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) return { value: num };
}
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
try {
return { value: JSON.parse(trimmed) };
} catch {
const unquoted = trimmed.slice(1, -1);
return { value: unquoted };
}
}
return { value: trimmed };
}
export function parseDebugCommand(raw: string): DebugCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
@@ -50,7 +84,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
message: "Usage: /debug set path=value",
};
}
const parsed = parseConfigValue(rawValue);
const parsed = parseDebugValue(rawValue);
if (parsed.error) {
return { action: "error", message: parsed.error };
}
+6 -10
View File
@@ -2,7 +2,6 @@ import {
resolveAgentConfig,
resolveAgentDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import {
isProfileInCooldown,
@@ -32,6 +31,7 @@ import {
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
type SessionEntry,
saveSessionStore,
@@ -491,18 +491,14 @@ export async function handleDirectiveOnly(params: {
currentReasoningLevel,
currentElevatedLevel,
} = params;
const activeAgentId = resolveSessionAgentId({
sessionKey: params.sessionKey,
config: params.cfg,
});
const activeAgentId = params.sessionKey
? resolveAgentIdFromSessionKey(params.sessionKey)
: resolveDefaultAgentId(params.cfg);
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
const runtimeIsSandboxed = (() => {
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({
@@ -1017,7 +1013,7 @@ export async function persistInlineDirectives(params: {
} = params;
let { provider, model } = params;
const activeAgentId = sessionKey
? resolveSessionAgentId({ sessionKey, config: cfg })
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId);
@@ -7,10 +7,6 @@ import type { ReplyDispatcher } from "./reply-dispatcher.js";
const mocks = vi.hoisted(() => ({
routeReply: vi.fn(async () => ({ ok: true, messageId: "mock" })),
tryFastAbortFromMessage: vi.fn(async () => ({
handled: false,
aborted: false,
})),
}));
vi.mock("./route-reply.js", () => ({
@@ -29,10 +25,6 @@ vi.mock("./route-reply.js", () => ({
routeReply: mocks.routeReply,
}));
vi.mock("./abort.js", () => ({
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
function createDispatcher(): ReplyDispatcher {
@@ -47,10 +39,6 @@ function createDispatcher(): ReplyDispatcher {
describe("dispatchReplyFromConfig", () => {
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
mocks.routeReply.mockClear();
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
@@ -72,10 +60,6 @@ describe("dispatchReplyFromConfig", () => {
});
it("routes when OriginatingChannel differs from Provider", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
mocks.routeReply.mockClear();
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
@@ -104,25 +88,4 @@ describe("dispatchReplyFromConfig", () => {
}),
);
});
it("fast-aborts without calling the reply resolver", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: true,
aborted: true,
});
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx: MsgContext = {
Provider: "telegram",
Body: "/stop",
};
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
text: "⚙️ Agent was aborted.",
});
});
});
+1 -33
View File
@@ -3,11 +3,10 @@ import { logVerbose } from "../../globals.js";
import { getReplyFromConfig } from "../reply.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { tryFastAbortFromMessage } from "./abort.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
export type DispatchFromConfigResult = {
type DispatchFromConfigResult = {
queuedFinal: boolean;
counts: Record<ReplyDispatchKind, number>;
};
@@ -67,37 +66,6 @@ export async function dispatchReplyFromConfig(params: {
}
};
const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
if (fastAbort.handled) {
const payload = { text: "⚙️ Agent was aborted." } satisfies ReplyPayload;
let queuedFinal = false;
let routedFinalCount = 0;
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
return { queuedFinal, counts };
}
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
@@ -1,34 +0,0 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import type { DispatchFromConfigResult } from "./dispatch-from-config.js";
import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
import {
createReplyDispatcherWithTyping,
type ReplyDispatcherWithTypingOptions,
} from "./reply-dispatcher.js";
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> {
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping(params.dispatcherOptions);
const result = await dispatchReplyFromConfig({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
markDispatchIdle();
return result;
}
-49
View File
@@ -1,49 +0,0 @@
import { splitMediaFromOutput } from "../../media/parse.js";
import { parseInlineDirectives } from "../../utils/directive-tags.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
export type ReplyDirectiveParseResult = {
text: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
replyToCurrent: boolean;
replyToTag: boolean;
audioAsVoice?: boolean;
isSilent: boolean;
};
export function parseReplyDirectives(
raw: string,
options: { currentMessageId?: string; silentToken?: string } = {},
): ReplyDirectiveParseResult {
const split = splitMediaFromOutput(raw);
let text = split.text ?? "";
const replyParsed = parseInlineDirectives(text, {
currentMessageId: options.currentMessageId,
stripAudioTag: false,
stripReplyTags: true,
});
if (replyParsed.hasReplyTag) {
text = replyParsed.text;
}
const silentToken = options.silentToken ?? SILENT_REPLY_TOKEN;
const isSilent = isSilentReplyText(text, silentToken);
if (isSilent) {
text = "";
}
return {
text,
mediaUrls: split.mediaUrls,
mediaUrl: split.mediaUrl,
replyToId: replyParsed.replyToId,
replyToCurrent: replyParsed.replyToCurrent,
replyToTag: replyParsed.hasReplyTag,
audioAsVoice: split.audioAsVoice,
isSilent,
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export type ReplyDispatcherOptions = {
onError?: ReplyDispatchErrorHandler;
};
export type ReplyDispatcherWithTypingOptions = Omit<
type ReplyDispatcherWithTypingOptions = Omit<
ReplyDispatcherOptions,
"onIdle"
> & {
+2 -18
View File
@@ -10,23 +10,8 @@ export function applyReplyTagsToPayload(
payload: ReplyPayload,
currentMessageId?: string,
): ReplyPayload {
if (typeof payload.text !== "string") {
if (!payload.replyToCurrent || payload.replyToId) return payload;
return {
...payload,
replyToId: currentMessageId?.trim() || undefined,
};
}
const shouldParseTags = payload.text.includes("[[");
if (!shouldParseTags) {
if (!payload.replyToCurrent || payload.replyToId) return payload;
return {
...payload,
replyToId: currentMessageId?.trim() || undefined,
replyToTag: payload.replyToTag ?? true,
};
}
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
if (typeof payload.text !== "string") return payload;
const { cleaned, replyToId, hasTag } = extractReplyToTag(
payload.text,
currentMessageId,
);
@@ -35,7 +20,6 @@ export function applyReplyTagsToPayload(
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
replyToTag: hasTag || payload.replyToTag,
replyToCurrent: replyToCurrent || payload.replyToCurrent,
};
}
+34 -12
View File
@@ -1,4 +1,12 @@
import { parseInlineDirectives } from "../../utils/directive-tags.js";
const REPLY_TAG_RE =
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
function normalizeReplyText(text: string) {
return text
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
export function extractReplyToTag(
text?: string,
@@ -6,17 +14,31 @@ export function extractReplyToTag(
): {
cleaned: string;
replyToId?: string;
replyToCurrent: boolean;
hasTag: boolean;
} {
const result = parseInlineDirectives(text, {
currentMessageId,
stripAudioTag: false,
});
return {
cleaned: result.text,
replyToId: result.replyToId,
replyToCurrent: result.replyToCurrent,
hasTag: result.hasReplyTag,
};
if (!text) return { cleaned: "", hasTag: false };
let sawCurrent = false;
let lastExplicitId: string | undefined;
let hasTag = false;
const cleaned = normalizeReplyText(
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
hasTag = true;
if (idRaw === undefined) {
sawCurrent = true;
return " ";
}
const id = idRaw.trim();
if (id) lastExplicitId = id;
return " ";
}),
);
const replyToId =
lastExplicitId ??
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
return { cleaned, replyToId, hasTag };
}
+2 -5
View File
@@ -7,12 +7,12 @@
* across multiple providers.
*/
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { sendMessageMSTeams } from "../../msteams/send.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
@@ -67,10 +67,7 @@ export async function routeReply(
const responsePrefix = params.sessionKey
? resolveEffectiveMessagesConfig(
cfg,
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: cfg,
}),
resolveAgentIdFromSessionKey(params.sessionKey),
).responsePrefix
: cfg.messages?.responsePrefix === "auto"
? undefined
+2 -5
View File
@@ -6,7 +6,6 @@ import {
CURRENT_SESSION_VERSION,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
buildGroupDisplayName,
@@ -14,6 +13,7 @@ import {
DEFAULT_RESET_TRIGGERS,
type GroupKeyResolution,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
resolveSessionFilePath,
resolveSessionKey,
@@ -92,10 +92,7 @@ export async function initSessionState(params: {
const { ctx, cfg, commandAuthorized } = params;
const sessionCfg = cfg.session;
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const agentId = resolveSessionAgentId({
sessionKey: ctx.SessionKey,
config: cfg,
});
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
+1 -1
View File
@@ -360,7 +360,7 @@ export function buildHelpMessage(): string {
return [
"ℹ️ Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
"More: /commands for all slash commands",
].join("\n");
}
-2
View File
@@ -31,8 +31,6 @@ export type ReplyPayload = {
mediaUrls?: string[];
replyToId?: string;
replyToTag?: boolean;
/** True when [[reply_to_current]] was present but not yet mapped to a message id. */
replyToCurrent?: boolean;
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;
-3
View File
@@ -7,7 +7,6 @@ import { registerBrowserRoutes } from "./routes/index.js";
import {
type BrowserServerState,
createBrowserRouteContext,
type ProfileContext,
} from "./server-context.js";
export type BrowserBridge = {
@@ -21,7 +20,6 @@ export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
onEnsureAttachTarget?: (profile: ProfileContext["profile"]) => Promise<void>;
}): Promise<BrowserBridge> {
const host = params.host ?? "127.0.0.1";
const port = params.port ?? 0;
@@ -38,7 +36,6 @@ export async function startBrowserBridgeServer(params: {
const ctx = createBrowserRouteContext({
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
registerBrowserRoutes(app, ctx);
-12
View File
@@ -85,7 +85,6 @@ export type ProfileStatus = {
type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
};
/**
@@ -260,13 +259,6 @@ function createProfileContext(
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if (
(current.resolved.attachOnly || remoteCdp) &&
opts.onEnsureAttachTarget
) {
await opts.onEnsureAttachTarget(profile);
if (await isHttpReachable(1200)) return;
}
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
@@ -292,10 +284,6 @@ function createProfileContext(
// We own it but WebSocket failed - restart
if (current.resolved.attachOnly || remoteCdp) {
if (opts.onEnsureAttachTarget) {
await opts.onEnsureAttachTarget(profile);
if (await isReachable(1200)) return;
}
throw new Error(
remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
-44
View File
@@ -707,50 +707,6 @@ describe("browser control server", () => {
expect(started.error ?? "").toMatch(/attachOnly/i);
});
it("allows attachOnly servers to ensure reachability via callback", async () => {
cfgAttachOnly = true;
reachable = false;
const { startBrowserBridgeServer } = await import("./bridge-server.js");
const ensured = vi.fn(async () => {
reachable = true;
});
const bridge = await startBrowserBridgeServer({
resolved: {
enabled: true,
controlUrl: "http://127.0.0.1:0",
controlHost: "127.0.0.1",
controlPort: 0,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
onEnsureAttachTarget: ensured,
});
const started = (await realFetch(`${bridge.baseUrl}/start`, {
method: "POST",
}).then((r) => r.json())) as { ok?: boolean; error?: string };
expect(started.error).toBeUndefined();
expect(started.ok).toBe(true);
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) =>
r.json(),
)) as { running?: boolean };
expect(status.running).toBe(true);
expect(ensured).toHaveBeenCalledTimes(1);
await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
});
it("opens tabs via CDP createTarget path", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
+1 -2
View File
@@ -67,7 +67,6 @@ import {
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
@@ -1287,7 +1286,7 @@ export async function runConfigureWizard(
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
} catch (err) {
runtime.error(formatHealthCheckFailure(err));
runtime.error(`Health check failed: ${String(err)}`);
note(
[
"Docs:",
+2 -3
View File
@@ -80,7 +80,6 @@ import {
shouldSuggestMemorySystem,
} from "./doctor-workspace.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { applyWizardMetadata, printWizardHeader } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
@@ -311,7 +310,7 @@ export async function doctorCommand(
note("Gateway not running.", "Gateway");
note(gatewayDetails.message, "Gateway connection");
} else {
runtime.error(formatHealthCheckFailure(err));
runtime.error(`Health check failed: ${message}`);
}
}
@@ -456,7 +455,7 @@ export async function doctorCommand(
note("Gateway not running.", "Gateway");
note(gatewayDetails.message, "Gateway connection");
} else {
runtime.error(formatHealthCheckFailure(err));
runtime.error(`Health check failed: ${message}`);
}
}
}
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatHealthCheckFailure } from "./health-format.js";
const stripAnsi = (input: string) =>
input.replace(
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip ANSI escape sequences
/\u001b\[[0-9;]*m/g,
"",
);
describe("formatHealthCheckFailure", () => {
it("keeps non-rich output stable", () => {
const err = new Error(
"gateway closed (1006 abnormal closure): no close reason",
);
expect(formatHealthCheckFailure(err, { rich: false })).toBe(
`Health check failed: ${String(err)}`,
);
});
it("formats gateway connection details as indented key/value lines", () => {
const err = new Error(
[
"gateway closed (1006 abnormal closure (no close frame)): no close reason",
"Gateway target: ws://127.0.0.1:19001",
"Source: local loopback",
"Config: /Users/steipete/.clawdbot-dev/clawdbot.json",
"Bind: loopback",
].join("\n"),
);
expect(stripAnsi(formatHealthCheckFailure(err, { rich: true }))).toBe(
[
"Health check failed: gateway closed (1006 abnormal closure (no close frame)): no close reason",
" Gateway target: ws://127.0.0.1:19001",
" Source: local loopback",
" Config: /Users/steipete/.clawdbot-dev/clawdbot.json",
" Bind: loopback",
].join("\n"),
);
});
});
-48
View File
@@ -1,48 +0,0 @@
import { colorize, isRich, theme } from "../terminal/theme.js";
const formatKv = (line: string, rich: boolean) => {
const idx = line.indexOf(": ");
if (idx <= 0) return colorize(rich, theme.muted, line);
const key = line.slice(0, idx);
const value = line.slice(idx + 2);
const valueColor =
key === "Gateway target" || key === "Config"
? theme.command
: key === "Source"
? theme.muted
: theme.info;
return `${colorize(rich, theme.muted, `${key}:`)} ${colorize(rich, valueColor, value)}`;
};
export function formatHealthCheckFailure(
err: unknown,
opts: { rich?: boolean } = {},
): string {
const rich = opts.rich ?? isRich();
const raw = String(err);
const message = err instanceof Error ? err.message : raw;
if (!rich) return `Health check failed: ${raw}`;
const lines = message
.split("\n")
.map((l) => l.trimEnd())
.filter(Boolean);
const detailsIdx = lines.findIndex((l) => l.startsWith("Gateway target: "));
const summaryLines = (detailsIdx >= 0 ? lines.slice(0, detailsIdx) : lines)
.map((l) => l.trim())
.filter(Boolean);
const detailLines = detailsIdx >= 0 ? lines.slice(detailsIdx) : [];
const summary = summaryLines.length > 0 ? summaryLines.join(" ") : message;
const header = colorize(rich, theme.error.bold, "Health check failed");
const out: string[] = [`${header}: ${summary}`];
for (const line of detailLines) {
out.push(` ${formatKv(line, rich)}`);
}
return out.join("\n");
}
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getConfigValueAtPath,
parseConfigPath,
setConfigValueAtPath,
unsetConfigValueAtPath,
} from "./config-paths.js";
describe("config paths", () => {
it("rejects empty and blocked paths", () => {
expect(parseConfigPath("")).toEqual({
ok: false,
error: "Invalid path. Use dot notation (e.g. foo.bar).",
});
expect(parseConfigPath("__proto__.polluted").ok).toBe(false);
});
it("sets, gets, and unsets nested values", () => {
const root: Record<string, unknown> = {};
const parsed = parseConfigPath("foo.bar");
if (!parsed.ok || !parsed.path) throw new Error("path parse failed");
setConfigValueAtPath(root, parsed.path, 123);
expect(getConfigValueAtPath(root, parsed.path)).toBe(123);
expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true);
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
});
});
-91
View File
@@ -1,91 +0,0 @@
type PathNode = Record<string, unknown>;
const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]);
export function parseConfigPath(raw: string): {
ok: boolean;
path?: string[];
error?: string;
} {
const trimmed = raw.trim();
if (!trimmed) {
return {
ok: false,
error: "Invalid path. Use dot notation (e.g. foo.bar).",
};
}
const parts = trimmed.split(".").map((part) => part.trim());
if (parts.some((part) => !part)) {
return {
ok: false,
error: "Invalid path. Use dot notation (e.g. foo.bar).",
};
}
if (parts.some((part) => BLOCKED_KEYS.has(part))) {
return { ok: false, error: "Invalid path segment." };
}
return { ok: true, path: parts };
}
export function setConfigValueAtPath(
root: PathNode,
path: string[],
value: unknown,
): void {
let cursor: PathNode = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) {
cursor[key] = {};
}
cursor = cursor[key] as PathNode;
}
cursor[path[path.length - 1]] = value;
}
export function unsetConfigValueAtPath(
root: PathNode,
path: string[],
): boolean {
const stack: Array<{ node: PathNode; key: string }> = [];
let cursor: PathNode = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) return false;
stack.push({ node: cursor, key });
cursor = next;
}
const leafKey = path[path.length - 1];
if (!(leafKey in cursor)) return false;
delete cursor[leafKey];
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
const { node, key } = stack[idx];
const child = node[key];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete node[key];
} else {
break;
}
}
return true;
}
export function getConfigValueAtPath(root: PathNode, path: string[]): unknown {
let cursor: unknown = root;
for (const key of path) {
if (!isPlainObject(cursor)) return undefined;
cursor = cursor[key];
}
return cursor;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
+70 -26
View File
@@ -1,14 +1,68 @@
import {
parseConfigPath,
setConfigValueAtPath,
unsetConfigValueAtPath,
} from "./config-paths.js";
import type { ClawdbotConfig } from "./types.js";
type OverrideTree = Record<string, unknown>;
let overrides: OverrideTree = {};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
function parsePath(raw: string): string[] | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split(".").map((part) => part.trim());
if (parts.some((part) => !part)) return null;
return parts;
}
function setOverrideAtPath(
root: OverrideTree,
path: string[],
value: unknown,
): void {
let cursor: OverrideTree = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) {
cursor[key] = {};
}
cursor = cursor[key] as OverrideTree;
}
cursor[path[path.length - 1]] = value;
}
function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean {
const stack: Array<{ node: OverrideTree; key: string }> = [];
let cursor: OverrideTree = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) return false;
stack.push({ node: cursor, key });
cursor = next;
}
const leafKey = path[path.length - 1];
if (!(leafKey in cursor)) return false;
delete cursor[leafKey];
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
const { node, key } = stack[idx];
const child = node[key];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete node[key];
} else {
break;
}
}
return true;
}
function mergeOverrides(base: unknown, override: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(override)) return override;
const next: OverrideTree = { ...base };
@@ -19,15 +73,6 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
export function getConfigOverrides(): OverrideTree {
return overrides;
}
@@ -43,11 +88,14 @@ export function setConfigOverride(
ok: boolean;
error?: string;
} {
const parsed = parseConfigPath(pathRaw);
if (!parsed.ok || !parsed.path) {
return { ok: false, error: parsed.error ?? "Invalid path." };
const path = parsePath(pathRaw);
if (!path) {
return {
ok: false,
error: "Invalid path. Use dot notation (e.g. foo.bar).",
};
}
setConfigValueAtPath(overrides, parsed.path, value);
setOverrideAtPath(overrides, path, value);
return { ok: true };
}
@@ -56,15 +104,11 @@ export function unsetConfigOverride(pathRaw: string): {
removed: boolean;
error?: string;
} {
const parsed = parseConfigPath(pathRaw);
if (!parsed.ok || !parsed.path) {
return {
ok: false,
removed: false,
error: parsed.error ?? "Invalid path.",
};
const path = parsePath(pathRaw);
if (!path) {
return { ok: false, removed: false, error: "Invalid path." };
}
const removed = unsetConfigValueAtPath(overrides, parsed.path);
const removed = unsetOverrideAtPath(overrides, path);
return { ok: true, removed };
}
-9
View File
@@ -827,13 +827,6 @@ export type SandboxBrowserSettings = {
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
/**
* When true (default), sandboxed browser control will try to start/reattach to
* the sandbox browser container when a tool call needs it.
*/
autoStart?: boolean;
/** Max time to wait for CDP to become reachable after auto-start (ms). */
autoStartTimeoutMs?: number;
};
export type SandboxPruneSettings = {
@@ -1006,8 +999,6 @@ export type MessagesConfig = {
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
/** Remove ack reaction after reply is sent (default: false). */
removeAckAfterReply?: boolean;
};
export type CommandsConfig = {
-3
View File
@@ -603,7 +603,6 @@ const MessagesSchema = z
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
.optional(),
removeAckAfterReply: z.boolean().optional(),
})
.optional();
@@ -697,8 +696,6 @@ const SandboxBrowserSchema = z
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(),
})
.optional();
+5 -17
View File
@@ -49,10 +49,6 @@ import { registerAgentRunContext } from "../infra/agent-events.js";
import { parseTelegramTarget } from "../telegram/targets.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164, truncateUtf16Safe } from "../utils.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../whatsapp/normalize.js";
import type { CronJob } from "./types.js";
export type RunCronAgentTurnResult = {
@@ -207,22 +203,15 @@ function resolveDeliveryTarget(
const sanitizedWhatsappTo = (() => {
if (provider !== "whatsapp") return rawTo;
if (rawTo && isWhatsAppGroupJid(rawTo)) {
return normalizeWhatsAppTarget(rawTo) ?? rawTo;
}
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) {
return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo;
}
if (rawAllow.includes("*")) return rawTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
if (allowFrom.length === 0) {
return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo;
}
if (allowFrom.length === 0) return rawTo;
if (!rawTo) return allowFrom[0];
const normalized = normalizeWhatsAppTarget(rawTo);
if (normalized && allowFrom.includes(normalized)) return normalized;
const normalized = normalizeE164(rawTo);
if (allowFrom.includes(normalized)) return normalized;
return allowFrom[0];
})();
@@ -554,8 +543,7 @@ export async function runCronIsolatedAgentTurn(params: {
summary: "Delivery skipped (no WhatsApp recipient).",
};
}
const rawTo = resolvedDelivery.to;
const to = normalizeWhatsAppTarget(rawTo) ?? rawTo;
const to = normalizeE164(resolvedDelivery.to);
try {
await deliverPayloadsWithMedia({
payloads,
+6 -16
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
@@ -12,11 +11,6 @@ import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
const formatLine = (label: string, value: string) => {
const rich = isRich();
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
@@ -384,9 +378,7 @@ export async function uninstallLegacyLaunchAgents({
const dest = path.join(trashDir, `${agent.label}.plist`);
try {
await fs.rename(agent.plistPath, dest);
stdout.write(
`${formatLine("Moved legacy LaunchAgent to Trash", dest)}\n`,
);
stdout.write(`Moved legacy LaunchAgent to Trash: ${dest}\n`);
} catch {
stdout.write(
`Legacy LaunchAgent remains at ${agent.plistPath} (could not move)\n`,
@@ -422,7 +414,7 @@ export async function uninstallLaunchAgent({
try {
await fs.mkdir(trashDir, { recursive: true });
await fs.rename(plistPath, dest);
stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`);
stdout.write(`Moved LaunchAgent to Trash: ${dest}\n`);
} catch {
stdout.write(`LaunchAgent remains at ${plistPath} (could not move)\n`);
}
@@ -454,7 +446,7 @@ export async function stopLaunchAgent({
`launchctl bootout failed: ${res.stderr || res.stdout}`.trim(),
);
}
stdout.write(`${formatLine("Stopped LaunchAgent", `${domain}/${label}`)}\n`);
stdout.write(`Stopped LaunchAgent: ${domain}/${label}\n`);
}
export async function installLaunchAgent({
@@ -515,8 +507,8 @@ export async function installLaunchAgent({
`${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`,
]);
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
stdout.write(`${formatLine("Logs", stdoutPath)}\n`);
stdout.write(`Installed LaunchAgent: ${plistPath}\n`);
stdout.write(`Logs: ${stdoutPath}\n`);
return { plistPath };
}
@@ -533,7 +525,5 @@ export async function restartLaunchAgent({
`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim(),
);
}
stdout.write(
`${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`,
);
stdout.write(`Restarted LaunchAgent: ${domain}/${label}\n`);
}
+6 -20
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
@@ -13,11 +12,6 @@ import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
const formatLine = (label: string, value: string) => {
const rich = isRich();
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.USERPROFILE?.trim() || env.HOME?.trim();
if (!home) throw new Error("Missing HOME");
@@ -235,10 +229,8 @@ export async function installScheduledTask({
}
await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
stdout.write(
`${formatLine("Installed Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`${formatLine("Task script", scriptPath)}\n`);
stdout.write(`Installed Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
stdout.write(`Task script: ${scriptPath}\n`);
return { scriptPath };
}
@@ -255,7 +247,7 @@ export async function uninstallScheduledTask({
const scriptPath = resolveTaskScriptPath(env);
try {
await fs.unlink(scriptPath);
stdout.write(`${formatLine("Removed task script", scriptPath)}\n`);
stdout.write(`Removed task script: ${scriptPath}\n`);
} catch {
stdout.write(`Task script not found at ${scriptPath}\n`);
}
@@ -280,9 +272,7 @@ export async function stopScheduledTask({
if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(
`${formatLine("Stopped Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`Stopped Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
}
export async function restartScheduledTask({
@@ -296,9 +286,7 @@ export async function restartScheduledTask({
if (res.code !== 0) {
throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(
`${formatLine("Restarted Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`Restarted Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
}
export async function isScheduledTaskInstalled(): Promise<boolean> {
@@ -412,9 +400,7 @@ export async function uninstallLegacyScheduledTasks({
try {
await fs.unlink(task.scriptPath);
stdout.write(
`${formatLine("Removed legacy task script", task.scriptPath)}\n`,
);
stdout.write(`Removed legacy task script: ${task.scriptPath}\n`);
} catch {
stdout.write(`Legacy task script not found at ${task.scriptPath}\n`);
}
+5 -13
View File
@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
@@ -14,11 +13,6 @@ import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
const formatLine = (label: string, value: string) => {
const rich = isRich();
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
@@ -416,7 +410,7 @@ export async function installSystemdService({
);
}
stdout.write(`${formatLine("Installed systemd service", unitPath)}\n`);
stdout.write(`Installed systemd service: ${unitPath}\n`);
return { unitPath };
}
@@ -434,7 +428,7 @@ export async function uninstallSystemdService({
const unitPath = resolveSystemdUnitPath(env);
try {
await fs.unlink(unitPath);
stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`);
stdout.write(`Removed systemd service: ${unitPath}\n`);
} catch {
stdout.write(`Systemd service not found at ${unitPath}\n`);
}
@@ -453,7 +447,7 @@ export async function stopSystemdService({
`systemctl stop failed: ${res.stderr || res.stdout}`.trim(),
);
}
stdout.write(`${formatLine("Stopped systemd service", unitName)}\n`);
stdout.write(`Stopped systemd service: ${unitName}\n`);
}
export async function restartSystemdService({
@@ -469,7 +463,7 @@ export async function restartSystemdService({
`systemctl restart failed: ${res.stderr || res.stdout}`.trim(),
);
}
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
stdout.write(`Restarted systemd service: ${unitName}\n`);
}
export async function isSystemdServiceEnabled(): Promise<boolean> {
@@ -590,9 +584,7 @@ export async function uninstallLegacySystemdUnits({
try {
await fs.unlink(unit.unitPath);
stdout.write(
`${formatLine("Removed legacy systemd service", unit.unitPath)}\n`,
);
stdout.write(`Removed legacy systemd service: ${unit.unitPath}\n`);
} catch {
stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`);
}
+23 -47
View File
@@ -51,7 +51,7 @@ import { formatDurationSeconds } from "../infra/format-duration.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
@@ -74,11 +74,7 @@ import {
waitForDiscordGatewayStop,
} from "./monitor.gateway.js";
import { fetchDiscordApplicationId } from "./probe.js";
import {
reactMessageDiscord,
removeReactionDiscord,
sendMessageDiscord,
} from "./send.js";
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = {
@@ -962,7 +958,6 @@ export function createDiscordMessageHandler(params: {
return;
}
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
@@ -977,19 +972,15 @@ export function createDiscordMessageHandler(params: {
}
return false;
};
const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
}).then(
() => true,
(err) => {
logVerbose(
`discord react failed for channel ${message.channelId}: ${String(err)}`,
);
return false;
},
)
: null;
if (shouldAckReaction()) {
reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
}).catch((err) => {
logVerbose(
`discord react failed for channel ${message.channelId}: ${String(err)}`,
);
});
}
const fromLabel = isDirectMessage
? buildDirectLabel(author)
@@ -1210,24 +1201,6 @@ export function createDiscordMessageHandler(params: {
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
const ackReactionValue = ackReaction;
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
removeReactionDiscord(
message.channelId,
message.id,
ackReactionValue,
{
rest: client.rest,
},
).catch((err) => {
logVerbose(
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
);
});
});
}
if (
isGuildMessage &&
shouldClearHistory &&
@@ -1879,16 +1852,19 @@ async function resolveMediaList(
const out: DiscordMediaInfo[] = [];
for (const attachment of attachments) {
try {
const fetched = await fetchRemoteMedia({
url: attachment.url,
filePathHint: attachment.filename ?? attachment.url,
const res = await fetch(attachment.url);
if (!res.ok) {
throw new Error(
`Failed to download discord attachment: HTTP ${res.status}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime = await detectMime({
buffer,
headerMime: attachment.content_type ?? res.headers.get("content-type"),
filePath: attachment.filename ?? attachment.url,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
maxBytes,
);
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
out.push({
path: saved.path,
contentType: saved.contentType,
@@ -1,338 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import { getApiKeyForModel } from "../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../agents/models-config.js";
import { loadConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { GatewayClient } from "./client.js";
import { startGatewayServer } from "./server.js";
import { getFreePort } from "./test-helpers.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const GATEWAY_LIVE = process.env.CLAWDBOT_LIVE_GATEWAY === "1";
const ALL_MODELS =
process.env.CLAWDBOT_LIVE_GATEWAY_ALL_MODELS === "1" ||
process.env.CLAWDBOT_LIVE_GATEWAY_MODELS === "all";
const describeLive = LIVE && GATEWAY_LIVE ? describe : describe.skip;
function parseFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") return null;
const ids = trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? new Set(ids) : null;
}
function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) =>
p && typeof p === "object"
? (p as Record<string, unknown>).text
: undefined,
)
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
function isMeaningful(text: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (trimmed.toLowerCase() === "ok") return false;
if (trimmed.length < 60) return false;
const words = trimmed.split(/\s+/g).filter(Boolean);
if (words.length < 12) return false;
return true;
}
type AgentFinalPayload = {
status?: unknown;
result?: unknown;
};
async function connectClient(params: { url: string; token: string }) {
return await new Promise<GatewayClient>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: GatewayClient) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as GatewayClient);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: "vitest-live",
clientVersion: "dev",
mode: "test",
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
timer.unref();
client.start();
});
}
describeLive("gateway live (dev agent, profile keys)", () => {
it(
"runs meaningful prompts across models with available keys",
async () => {
const previous = {
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const cfg = loadConfig();
await ensureClawdbotModelsJson(cfg);
const workspaceDir = resolveUserPath(
cfg.agents?.defaults?.workspace ?? path.join(os.homedir(), "clawd"),
);
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(
workspaceDir,
`.clawdbot-live-tool-probe.${nonceA}.txt`,
);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const all = modelRegistry.getAll() as Array<Model<Api>>;
const filter = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_MODELS);
// Default: honor user allowlist. Opt-in: scan all models with keys.
const allowlistKeys = Object.keys(cfg.agents?.defaults?.models ?? {});
const wanted =
ALL_MODELS || allowlistKeys.length === 0
? all
: all.filter((m) => allowlistKeys.includes(`${m.provider}/${m.id}`));
const candidates: Array<Model<Api>> = [];
for (const model of wanted) {
const id = `${model.provider}/${model.id}`;
if (filter && !filter.has(id)) continue;
try {
// eslint-disable-next-line no-await-in-loop
await getApiKeyForModel({ model, cfg });
candidates.push(model);
} catch {
// no creds; skip
}
}
expect(candidates.length).toBeGreaterThan(0);
// Build a temp config that allows all selected models, so session overrides stick.
const nextCfg = {
...cfg,
agents: {
...(cfg.agents ?? {}),
defaults: {
...(cfg.agents?.defaults ?? {}),
models: Object.fromEntries(
candidates.map((m) => [`${m.provider}/${m.id}`, {}]),
),
},
},
};
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-live-"),
);
const tempConfigPath = path.join(tempDir, "clawdbot.json");
await fs.writeFile(
tempConfigPath,
`${JSON.stringify(nextCfg, null, 2)}\n`,
);
process.env.CLAWDBOT_CONFIG_PATH = tempConfigPath;
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectClient({
url: `ws://127.0.0.1:${port}`,
token,
});
try {
const sessionKey = "agent:dev:live-gateway";
const failures: Array<{ model: string; error: string }> = [];
for (const model of candidates) {
const modelKey = `${model.provider}/${model.id}`;
try {
// Ensure session exists + override model for this run.
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: modelKey,
});
// “Meaningful” direct prompt (no tools).
const runId = randomUUID();
const payload = await client.request<AgentFinalPayload>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
deliver: false,
},
{ expectFinal: true },
);
if (payload?.status !== "ok") {
throw new Error(`agent status=${String(payload?.status)}`);
}
const text = extractPayloadText(payload?.result);
if (!isMeaningful(text)) throw new Error(`not meaningful: ${text}`);
if (
!/\\bmicrotask\\b/i.test(text) ||
!/\\bmacrotask\\b/i.test(text)
) {
throw new Error(`missing required keywords: ${text}`);
}
// Real tool invocation: force the agent to Read a local file and echo a nonce.
const runIdTool = randomUUID();
const toolProbe = await client.request<AgentFinalPayload>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runIdTool}-tool`,
message:
`Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
if (toolProbe?.status !== "ok") {
throw new Error(
`tool probe failed: status=${String(toolProbe?.status)}`,
);
}
const toolText = extractPayloadText(toolProbe?.result);
if (!toolText.includes(nonceA) || !toolText.includes(nonceB)) {
throw new Error(`tool probe missing nonce: ${toolText}`);
}
// Regression: tool-call-only turn followed by a user message (OpenAI responses bug class).
if (
(model.provider === "openai" &&
model.api === "openai-responses") ||
(model.provider === "openai-codex" &&
model.api === "openai-codex-responses")
) {
const runId2 = randomUUID();
const first = await client.request<AgentFinalPayload>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId2}-1`,
message:
"Call the tool named `read` (or `Read`) on package.json. Do not write any other text.",
deliver: false,
},
{ expectFinal: true },
);
if (first?.status !== "ok") {
throw new Error(
`tool-only turn failed: status=${String(first?.status)}`,
);
}
const second = await client.request<AgentFinalPayload>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId2}-2`,
message:
'Now answer: what is the "version" field in package.json? Reply with just the version string.',
deliver: false,
},
{ expectFinal: true },
);
if (second?.status !== "ok") {
throw new Error(
`post-tool message failed: status=${String(second?.status)}`,
);
}
const version = extractPayloadText(second?.result);
if (!/^\\d{4}\\.\\d+\\.\\d+/.test(version.trim())) {
throw new Error(`unexpected version: ${version}`);
}
}
} catch (err) {
failures.push({ model: modelKey, error: String(err) });
}
}
if (failures.length > 0) {
const preview = failures
.slice(0, 20)
.map((f) => `- ${f.model}: ${f.error}`)
.join("\n");
throw new Error(
`gateway live model failures (${failures.length}):\n${preview}`,
);
}
} finally {
client.stop();
await server.close({ reason: "live test complete" });
await fs.rm(toolProbePath, { force: true });
await fs.rm(tempDir, { recursive: true, force: true });
process.env.CLAWDBOT_CONFIG_PATH = previous.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = previous.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;
}
},
20 * 60 * 1000,
);
});
@@ -1,426 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
type OpenAIResponsesParams = {
input?: unknown[];
};
type OpenAIResponseStreamEvent =
| { type: "response.output_item.added"; item: Record<string, unknown> }
| { type: "response.function_call_arguments.delta"; delta: string }
| { type: "response.output_item.done"; item: Record<string, unknown> }
| {
type: "response.completed";
response: {
status: "completed";
usage: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: { cached_tokens?: number };
};
};
};
function extractLastUserText(input: unknown[]): string {
for (let i = input.length - 1; i >= 0; i -= 1) {
const item = input[i] as Record<string, unknown> | undefined;
if (!item || item.role !== "user") continue;
const content = item.content;
if (Array.isArray(content)) {
const text = content
.filter(
(c): c is { type: "input_text"; text: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: unknown }).type === "input_text" &&
typeof (c as { text?: unknown }).text === "string",
)
.map((c) => c.text)
.join("\n")
.trim();
if (text) return text;
}
}
return "";
}
function extractToolOutput(input: unknown[]): string {
for (const itemRaw of input) {
const item = itemRaw as Record<string, unknown> | undefined;
if (!item || item.type !== "function_call_output") continue;
return typeof item.output === "string" ? item.output : "";
}
return "";
}
async function* fakeOpenAIResponsesStream(
params: OpenAIResponsesParams,
): AsyncGenerator<OpenAIResponseStreamEvent> {
const input = Array.isArray(params.input) ? params.input : [];
const toolOutput = extractToolOutput(input);
// Turn 1: return a tool call to `read`.
if (!toolOutput) {
const prompt = extractLastUserText(input);
const quoted = /"([^"]+)"/.exec(prompt)?.[1];
const toolPath = quoted ?? "package.json";
const argsJson = JSON.stringify({ path: toolPath });
yield {
type: "response.output_item.added",
item: {
type: "function_call",
id: "fc_test_1",
call_id: "call_test_1",
name: "read",
arguments: "",
},
};
yield { type: "response.function_call_arguments.delta", delta: argsJson };
yield {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_test_1",
call_id: "call_test_1",
name: "read",
arguments: argsJson,
},
};
yield {
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
},
};
return;
}
// Turn 2: echo the nonces extracted from the Read tool output.
const nonceA = /nonceA=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
const nonceB = /nonceB=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
const reply = `${nonceA} ${nonceB}`.trim();
yield {
type: "response.output_item.added",
item: {
type: "message",
id: "msg_test_1",
role: "assistant",
content: [],
status: "in_progress",
},
};
yield {
type: "response.output_item.done",
item: {
type: "message",
id: "msg_test_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: reply, annotations: [] }],
},
};
yield {
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
},
};
}
function decodeBodyText(body: unknown): string {
if (!body) return "";
if (typeof body === "string") return body;
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
if (body instanceof ArrayBuffer)
return Buffer.from(new Uint8Array(body)).toString("utf8");
return "";
}
async function buildOpenAIResponsesSse(
params: OpenAIResponsesParams,
): Promise<Response> {
const events: OpenAIResponseStreamEvent[] = [];
for await (const event of fakeOpenAIResponsesStream(params)) {
events.push(event);
}
const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`;
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = createServer();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
reject(new Error("failed to acquire free port"));
return;
}
const port = addr.port;
srv.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
async function isPortFree(port: number): Promise<boolean> {
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
return await new Promise((resolve) => {
const srv = createServer();
srv.once("error", () => resolve(false));
srv.listen(port, "127.0.0.1", () => {
srv.close(() => resolve(true));
});
});
}
async function getFreeGatewayPort(): Promise<number> {
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
// ensuring the common derived offsets are free too.
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
).every(Boolean);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
}
function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) =>
p && typeof p === "object"
? (p as Record<string, unknown>).text
: undefined,
)
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
async function connectClient(params: { url: string; token: string }) {
const { GatewayClient } = await import("./client.js");
return await new Promise<InstanceType<typeof GatewayClient>>(
(resolve, reject) => {
let settled = false;
const stop = (
err?: Error,
client?: InstanceType<typeof GatewayClient>,
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: "vitest-mock-openai",
clientVersion: "dev",
mode: "test",
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
timer.unref();
client.start();
},
);
}
describe("gateway (mock openai): tool calling", () => {
it("runs a Read tool call end-to-end via gateway agent loop", async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
};
const originalFetch = globalThis.fetch;
const openaiResponsesUrl = "https://api.openai.com/v1/responses";
const fetchImpl = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url === openaiResponsesUrl) {
const bodyText =
typeof (init as { body?: unknown } | undefined)?.body !== "undefined"
? decodeBodyText((init as { body?: unknown }).body)
: input instanceof Request
? await input.clone().text()
: "";
const parsed = bodyText
? (JSON.parse(bodyText) as Record<string, unknown>)
: {};
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
return await buildOpenAIResponsesSse({ input: inputItems });
}
if (!originalFetch) {
throw new Error(`fetch is not available (url=${url})`);
}
return await originalFetch(input, init);
};
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
);
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_PROVIDERS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const workspaceDir = path.join(tempHome, "clawd");
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(
workspaceDir,
`.clawdbot-tool-probe.${nonceA}.txt`,
);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const configDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "clawdbot.json");
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
models: {
mode: "replace",
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test",
api: "openai-responses",
models: [
{
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
},
],
},
},
},
gateway: { auth: { token } },
};
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.env.CLAWDBOT_CONFIG_PATH = configPath;
const port = await getFreeGatewayPort();
const { startGatewayServer } = await import("./server.js");
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectClient({
url: `ws://127.0.0.1:${port}`,
token,
});
try {
const sessionKey = "agent:dev:mock-openai";
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
});
const runId = randomUUID();
const payload = await client.request<{
status?: unknown;
result?: unknown;
}>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
`Call the read tool on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
expect(payload?.status).toBe("ok");
const text = extractPayloadText(payload?.result);
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_SKIP_PROVIDERS = prev.skipProviders;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
}
}, 30_000);
});
+3 -17
View File
@@ -19,10 +19,6 @@ import {
normalizeMessageProvider,
} from "../../utils/message-provider.js";
import { normalizeE164 } from "../../utils.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../whatsapp/normalize.js";
import {
type AgentWaitParams,
ErrorCodes,
@@ -225,21 +221,11 @@ export const agentHandlers: GatewayRequestHandlers = {
typeof request.to === "string" && request.to.trim()
? request.to.trim()
: undefined;
if (explicit) {
if (!resolvedTo) return resolvedTo;
return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo;
}
if (resolvedTo && isWhatsAppGroupJid(resolvedTo)) {
return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo;
}
if (explicit) return resolvedTo;
const cfg = cfgForAgent ?? loadConfig();
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) {
return resolvedTo
? (normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo)
: resolvedTo;
}
if (rawAllow.includes("*")) return resolvedTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
@@ -247,7 +233,7 @@ export const agentHandlers: GatewayRequestHandlers = {
const normalizedLast =
typeof resolvedTo === "string" && resolvedTo.trim()
? normalizeWhatsAppTarget(resolvedTo)
? normalizeE164(resolvedTo)
: undefined;
if (normalizedLast && allowFrom.includes(normalizedLast)) {
return normalizedLast;
-45
View File
@@ -96,21 +96,6 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
});
it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" },
},
},
whatsapp: { allowFrom: ["*"] },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
provider: "whatsapp",
to: "+555123",
});
});
it("skips when last route is webchat", () => {
const cfg: ClawdbotConfig = {};
const entry = {
@@ -141,36 +126,6 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
});
it("keeps WhatsApp group targets even with allowFrom set", () => {
const cfg: ClawdbotConfig = {
whatsapp: { allowFrom: ["+1555"] },
};
const entry = {
...baseEntry,
lastProvider: "whatsapp" as const,
lastTo: "120363401234567890@g.us",
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
provider: "whatsapp",
to: "120363401234567890@g.us",
});
});
it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => {
const cfg: ClawdbotConfig = {
whatsapp: { allowFrom: ["+1555"] },
};
const entry = {
...baseEntry,
lastProvider: "whatsapp" as const,
lastTo: "whatsapp:group:120363401234567890@G.US",
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
provider: "whatsapp",
to: "120363401234567890@g.us",
});
});
it("keeps explicit telegram targets", () => {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
+16 -58
View File
@@ -3,64 +3,22 @@ import { describe, expect, it } from "vitest";
import { resolveOutboundTarget } from "./targets.js";
describe("resolveOutboundTarget", () => {
it.each([
{
name: "normalizes whatsapp target when provided",
input: { provider: "whatsapp" as const, to: " (555) 123-4567 " },
expected: { ok: true as const, to: "+5551234567" },
},
{
name: "keeps whatsapp group targets",
input: { provider: "whatsapp" as const, to: "120363401234567890@g.us" },
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "normalizes prefixed/uppercase whatsapp group targets",
input: {
provider: "whatsapp" as const,
to: " WhatsApp:Group:120363401234567890@G.US ",
},
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "falls back to whatsapp allowFrom",
input: { provider: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
expected: { ok: true as const, to: "+1555" },
},
{
name: "normalizes whatsapp allowFrom fallback targets",
input: {
provider: "whatsapp" as const,
to: "",
allowFrom: ["whatsapp:(555) 123-4567"],
},
expected: { ok: true as const, to: "+5551234567" },
},
{
name: "rejects invalid whatsapp target",
input: { provider: "whatsapp" as const, to: "wat" },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp without to when allowFrom missing",
input: { provider: "whatsapp" as const, to: " " },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp allowFrom fallback when invalid",
input: { provider: "whatsapp" as const, to: "", allowFrom: ["wat"] },
expectedErrorIncludes: "WhatsApp",
},
])("$name", ({ input, expected, expectedErrorIncludes }) => {
const res = resolveOutboundTarget(input);
if (expected) {
expect(res).toEqual(expected);
return;
}
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain(expectedErrorIncludes);
}
it("falls back to whatsapp allowFrom", () => {
const res = resolveOutboundTarget({
provider: "whatsapp",
to: "",
allowFrom: ["+1555"],
});
expect(res).toEqual({ ok: true, to: "+1555" });
});
it("normalizes whatsapp target when provided", () => {
const res = resolveOutboundTarget({
provider: "whatsapp",
to: " (555) 123-4567 ",
});
if (!res.ok) throw res.error;
expect(res.to).toBe("+5551234567");
});
it("rejects telegram with missing target", () => {
+6 -31
View File
@@ -5,10 +5,6 @@ import type {
GatewayMessageProvider,
} from "../../utils/message-provider.js";
import { normalizeE164 } from "../../utils.js";
import {
isWhatsAppGroupJid,
normalizeWhatsAppTarget,
} from "../../whatsapp/normalize.js";
export type OutboundProvider = DeliverableMessageProvider | "none";
@@ -24,7 +20,7 @@ export type OutboundTargetResolution =
| { ok: true; to: string }
| { ok: false; error: Error };
export function normalizeOutboundTarget(params: {
export function resolveOutboundTarget(params: {
provider: GatewayMessageProvider;
to?: string;
allowFrom?: string[];
@@ -32,28 +28,16 @@ export function normalizeOutboundTarget(params: {
const trimmed = params.to?.trim() || "";
if (params.provider === "whatsapp") {
if (trimmed) {
const normalized = normalizeWhatsAppTarget(trimmed);
if (!normalized) {
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or whatsapp.allowFrom[0]",
),
};
}
return { ok: true, to: normalized };
return { ok: true, to: normalizeE164(trimmed) };
}
const fallback = params.allowFrom?.[0]?.trim();
if (fallback) {
const normalized = normalizeWhatsAppTarget(fallback);
if (normalized) {
return { ok: true, to: normalized };
}
return { ok: true, to: fallback };
}
return {
ok: false,
error: new Error(
"Delivering to WhatsApp requires --to <E.164|group JID> or whatsapp.allowFrom[0]",
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
),
};
}
@@ -129,14 +113,6 @@ export function normalizeOutboundTarget(params: {
};
}
export function resolveOutboundTarget(params: {
provider: GatewayMessageProvider;
to?: string;
allowFrom?: string[];
}): OutboundTargetResolution {
return normalizeOutboundTarget(params);
}
export function resolveHeartbeatDeliveryTarget(params: {
cfg: ClawdbotConfig;
entry?: SessionEntry;
@@ -202,14 +178,14 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
if (provider !== "whatsapp") {
const resolved = normalizeOutboundTarget({ provider, to });
const resolved = resolveOutboundTarget({ provider, to });
return resolved.ok
? { provider, to: resolved.to }
: { provider: "none", reason: "no-target" };
}
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
const resolved = normalizeOutboundTarget({
const resolved = resolveOutboundTarget({
provider: "whatsapp",
to,
allowFrom: rawAllow,
@@ -218,7 +194,6 @@ export function resolveHeartbeatDeliveryTarget(params: {
return { provider: "none", reason: "no-target" };
}
if (rawAllow.includes("*")) return { provider, to: resolved.to };
if (isWhatsAppGroupJid(resolved.to)) return { provider, to: resolved.to };
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
-19
View File
@@ -1,19 +0,0 @@
import { parseInlineDirectives } from "../utils/directive-tags.js";
/**
* Extract audio mode tag from text.
* Supports [[audio_as_voice]] to send audio as voice bubble instead of file.
* Default is file (preserves backward compatibility).
*/
export function parseAudioTag(text?: string): {
text: string;
audioAsVoice: boolean;
hadTag: boolean;
} {
const result = parseInlineDirectives(text, { stripReplyTags: false });
return {
text: result.text,
audioAsVoice: result.audioAsVoice,
hadTag: result.hasAudioTag,
};
}
-18
View File
@@ -1,18 +0,0 @@
import { getFileExtension } from "./mime.js";
const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus"]);
export function isVoiceCompatibleAudio(opts: {
contentType?: string | null;
fileName?: string | null;
}): boolean {
const mime = opts.contentType?.toLowerCase();
if (mime && (mime.includes("ogg") || mime.includes("opus"))) {
return true;
}
const fileName = opts.fileName?.trim();
if (!fileName) return false;
const ext = getFileExtension(fileName);
if (!ext) return false;
return VOICE_AUDIO_EXTENSIONS.has(ext);
}
-130
View File
@@ -1,130 +0,0 @@
import path from "node:path";
import { detectMime, extensionForMime } from "./mime.js";
type FetchMediaResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type FetchLike = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
type FetchMediaOptions = {
url: string;
fetchImpl?: FetchLike;
filePathHint?: string;
};
function stripQuotes(value: string): string {
return value.replace(/^["']|["']$/g, "");
}
function parseContentDispositionFileName(
header?: string | null,
): string | undefined {
if (!header) return undefined;
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
if (starMatch?.[1]) {
const cleaned = stripQuotes(starMatch[1].trim());
const encoded = cleaned.split("''").slice(1).join("''") || cleaned;
try {
return path.basename(decodeURIComponent(encoded));
} catch {
return path.basename(encoded);
}
}
const match = /filename\s*=\s*([^;]+)/i.exec(header);
if (match?.[1]) return path.basename(stripQuotes(match[1].trim()));
return undefined;
}
async function readErrorBodySnippet(
res: Response,
maxChars = 200,
): Promise<string | undefined> {
try {
const text = await res.text();
if (!text) return undefined;
const collapsed = text.replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
if (collapsed.length <= maxChars) return collapsed;
return `${collapsed.slice(0, maxChars)}…`;
} catch {
return undefined;
}
}
export async function fetchRemoteMedia(
options: FetchMediaOptions,
): Promise<FetchMediaResult> {
const { url, fetchImpl, filePathHint } = options;
const fetcher: FetchLike | undefined = fetchImpl ?? globalThis.fetch;
if (!fetcher) {
throw new Error("fetch is not available");
}
let res: Response;
try {
res = await fetcher(url);
} catch (err) {
throw new Error(`Failed to fetch media from ${url}: ${String(err)}`);
}
if (!res.ok) {
const statusText = res.statusText ? ` ${res.statusText}` : "";
const redirected =
res.url && res.url !== url ? ` (redirected to ${res.url})` : "";
let detail = `HTTP ${res.status}${statusText}`;
if (!res.body) {
detail = `HTTP ${res.status}${statusText}; empty response body`;
} else {
const snippet = await readErrorBodySnippet(res);
if (snippet) detail += `; body: ${snippet}`;
}
throw new Error(
`Failed to fetch media from ${url}${redirected}: ${detail}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
let fileNameFromUrl: string | undefined;
try {
const parsed = new URL(url);
const base = path.basename(parsed.pathname);
fileNameFromUrl = base || undefined;
} catch {
// ignore parse errors; leave undefined
}
const headerFileName = parseContentDispositionFileName(
res.headers.get("content-disposition"),
);
let fileName =
headerFileName ||
fileNameFromUrl ||
(filePathHint ? path.basename(filePathHint) : undefined);
const filePathForMime =
headerFileName && path.extname(headerFileName)
? headerFileName
: (filePathHint ?? url);
const contentType = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),
filePath: filePathForMime,
});
if (fileName && !path.extname(fileName) && contentType) {
const ext = extensionForMime(contentType);
if (ext) fileName = `${fileName}${ext}`;
}
return {
buffer,
contentType: contentType ?? undefined,
fileName,
};
}
+5 -20
View File
@@ -36,17 +36,6 @@ const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".opus",
".wav",
]);
function normalizeHeaderMime(mime?: string | null): string | undefined {
if (!mime) return undefined;
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
@@ -63,7 +52,7 @@ async function sniffMime(buffer?: Buffer): Promise<string | undefined> {
}
}
export function getFileExtension(filePath?: string | null): string | undefined {
function extFromPath(filePath?: string): string | undefined {
if (!filePath) return undefined;
try {
if (/^https?:\/\//i.test(filePath)) {
@@ -77,12 +66,6 @@ export function getFileExtension(filePath?: string | null): string | undefined {
return ext || undefined;
}
export function isAudioFileName(fileName?: string | null): boolean {
const ext = getFileExtension(fileName);
if (!ext) return false;
return AUDIO_FILE_EXTENSIONS.has(ext);
}
export function detectMime(opts: {
buffer?: Buffer;
headerMime?: string | null;
@@ -102,7 +85,7 @@ async function detectMimeImpl(opts: {
headerMime?: string | null;
filePath?: string;
}): Promise<string | undefined> {
const ext = getFileExtension(opts.filePath);
const ext = extFromPath(opts.filePath);
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
const headerMime = normalizeHeaderMime(opts.headerMime);
@@ -129,7 +112,9 @@ export function isGifMedia(opts: {
fileName?: string | null;
}): boolean {
if (opts.contentType?.toLowerCase() === "image/gif") return true;
const ext = getFileExtension(opts.fileName);
const ext = opts.fileName
? path.extname(opts.fileName).toLowerCase()
: undefined;
return ext === ".gif";
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more