Merge branch 'main' into docs/fix-slack-scopes
This commit is contained in:
commit
3b6b8e7696
13
CHANGELOG.md
13
CHANGELOG.md
@ -4,10 +4,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
|
||||
### Fixes
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Linux: prompt to enable systemd lingering when installing/restarting the gateway user service (prevents logout/idle shutdowns).
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||
@ -16,6 +20,7 @@
|
||||
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
|
||||
- Model: `/model list` is an alias for `/model`.
|
||||
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
|
||||
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||
@ -27,11 +32,17 @@
|
||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||
- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3).
|
||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
||||
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
||||
|
||||
### Maintenance
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
- Skills: add CodexBar model usage helper with macOS requirement metadata.
|
||||
- Skills: add 1Password CLI skill with op examples.
|
||||
- Lint: organize imports and wrap long lines in reply commands.
|
||||
- Deps: update to latest across the repo.
|
||||
|
||||
## 2026.1.5-3
|
||||
|
||||
|
||||
282
README.md
282
README.md
@ -16,15 +16,20 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Website: [clawdbot.com](https://clawdbot.com) · Docs: [docs.clawdbot.com](https://docs.clawdbot.com/) · FAQ: [FAQ](https://docs.clawdbot.com/faq) · Wizard: [Wizard](https://docs.clawdbot.com/wizard) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [Docker](https://docs.clawdbot.com/docker) · Discord: [discord.gg/clawd](https://discord.gg/clawd)
|
||||
Website: [https://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
Works with npm, pnpm, or bun.
|
||||
|
||||
Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See [Onboarding](https://docs.clawdbot.com/onboarding).
|
||||
**Subscriptions (OAuth):**
|
||||
- **Anthropic** (Claude Pro/Max)
|
||||
- **OpenAI** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding).
|
||||
|
||||
## Recommended setup (from source)
|
||||
|
||||
@ -75,69 +80,128 @@ If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Slack, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes).
|
||||
- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||
- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor).
|
||||
- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups).
|
||||
- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio).
|
||||
|
||||
### Surfaces + providers
|
||||
- WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||
- [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat).
|
||||
- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface).
|
||||
|
||||
### Apps + nodes
|
||||
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||
- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control.
|
||||
- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- Canvas: A2UI push/reset, eval, snapshot.
|
||||
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||
- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- Control UI + WebChat served directly from the Gateway.
|
||||
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs.
|
||||
- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS/Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)).
|
||||
- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the bash tool and provider connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.clawdbot.com/session-tool)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://clawdhub.com
|
||||
https://ClawdHub.com
|
||||
|
||||
## Chat commands
|
||||
|
||||
@ -154,6 +218,13 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
@ -161,7 +232,7 @@ The Gateway alone delivers a great experience. All apps are optional and add ext
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
@ -169,13 +240,13 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.clawdbot.com/ios/connect).
|
||||
Runbook: [iOS connect](https://docs.clawdbot.com/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawdbot.com/android/connect).
|
||||
- Runbook: [Android connect](https://docs.clawdbot.com/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@ -197,12 +268,20 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration)
|
||||
|
||||
### WhatsApp
|
||||
## Security model (important)
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawdbot.com/whatsapp)
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
### [Telegram](https://docs.clawdbot.com/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
@ -215,7 +294,11 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
### Discord
|
||||
### [Slack](https://docs.clawdbot.com/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawdbot.com/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
@ -228,6 +311,18 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawdbot.com/signal)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawdbot.com/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
|
||||
### [WebChat](https://docs.clawdbot.com/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
@ -242,36 +337,97 @@ Browser control (optional):
|
||||
|
||||
## Docs
|
||||
|
||||
[Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/)
|
||||
[Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture)
|
||||
[Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration)
|
||||
[Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway)
|
||||
[Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web)
|
||||
[Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote)
|
||||
[Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard)
|
||||
[Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook)
|
||||
[Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub)
|
||||
[Learn the macOS menu bar companion details.](https://clawdbot.com/clawdbot-mac.html)
|
||||
[Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting)
|
||||
[Review security guidance before exposing anything.](https://docs.clawdbot.com/security)
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar)
|
||||
- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.clawdbot.com/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour)
|
||||
- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawdbot.com/control-ui)
|
||||
- [Dashboard](https://docs.clawdbot.com/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.clawdbot.com/health)
|
||||
- [Gateway lock](https://docs.clawdbot.com/gateway-lock)
|
||||
- [Background process](https://docs.clawdbot.com/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawdbot.com/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.clawdbot.com/agent-loop)
|
||||
- [Presence](https://docs.clawdbot.com/presence)
|
||||
- [TypeBox schemas](https://docs.clawdbot.com/typebox)
|
||||
- [RPC adapters](https://docs.clawdbot.com/rpc)
|
||||
- [Queue](https://docs.clawdbot.com/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.clawdbot.com/skills-config)
|
||||
- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawdbot.com/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake)
|
||||
- [iOS node](https://docs.clawdbot.com/ios)
|
||||
- [Android node](https://docs.clawdbot.com/android)
|
||||
- [Windows app](https://docs.clawdbot.com/windows)
|
||||
- [Linux app](https://docs.clawdbot.com/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub)
|
||||
|
||||
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@gmail.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="Nachx639" title="Nachx639"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></a>
|
||||
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
|
||||
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
</p>
|
||||
|
||||
@ -8,26 +8,26 @@ read_when:
|
||||
|
||||
## First run (recommended)
|
||||
|
||||
Clawdbot uses a dedicated workspace directory for the agent. Default: `~/.clawdbot/workspace`.
|
||||
Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agent.workspace`).
|
||||
|
||||
1) Create the workspace (if it doesn’t already exist):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.clawdbot/workspace
|
||||
mkdir -p ~/clawd
|
||||
```
|
||||
|
||||
2) Copy the default workspace templates into the workspace:
|
||||
|
||||
```bash
|
||||
cp docs/templates/AGENTS.md ~/.clawdbot/workspace/AGENTS.md
|
||||
cp docs/templates/SOUL.md ~/.clawdbot/workspace/SOUL.md
|
||||
cp docs/templates/TOOLS.md ~/.clawdbot/workspace/TOOLS.md
|
||||
cp docs/templates/AGENTS.md ~/clawd/AGENTS.md
|
||||
cp docs/templates/SOUL.md ~/clawd/SOUL.md
|
||||
cp docs/templates/TOOLS.md ~/clawd/TOOLS.md
|
||||
```
|
||||
|
||||
3) Optional: if you want the personal assistant skill roster, replace AGENTS.md with this file:
|
||||
|
||||
```bash
|
||||
cp docs/AGENTS.default.md ~/.clawdbot/workspace/AGENTS.md
|
||||
cp docs/AGENTS.default.md ~/clawd/AGENTS.md
|
||||
```
|
||||
|
||||
4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`):
|
||||
@ -73,7 +73,7 @@ cp docs/AGENTS.default.md ~/.clawdbot/workspace/AGENTS.md
|
||||
If you treat this workspace as Clawd’s “memory”, make it a git repo (ideally private) so `AGENTS.md` and your memory files are backed up.
|
||||
|
||||
```bash
|
||||
cd ~/.clawdbot/workspace
|
||||
cd ~/clawd
|
||||
git init
|
||||
git add AGENTS.md
|
||||
git commit -m "Add Clawd workspace"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
title: "CLAWDBOT Docs"
|
||||
description: "A TypeScript/Node gateway + macOS/iOS companions for WhatsApp (web) and Telegram (bot)."
|
||||
description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)."
|
||||
markdown: kramdown
|
||||
highlighter: rouge
|
||||
|
||||
@ -35,9 +35,11 @@ nav:
|
||||
- title: "WebChat"
|
||||
url: "/webchat.html"
|
||||
- title: "macOS App"
|
||||
url: "/clawdbot-mac.html"
|
||||
- title: "iOS Node"
|
||||
url: "/ios/connect.html"
|
||||
url: "/macos.html"
|
||||
- title: "iOS App"
|
||||
url: "/ios.html"
|
||||
- title: "Android App"
|
||||
url: "/android.html"
|
||||
- title: "Telegram"
|
||||
url: "/telegram.html"
|
||||
- title: "Security"
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
---
|
||||
summary: "Runbook: connect/pair the Android node to a Clawdbot Gateway and use Canvas/Chat/Camera"
|
||||
summary: "Android app (node): connection runbook + Canvas/Chat/Camera"
|
||||
read_when:
|
||||
- Pairing or reconnecting the Android node
|
||||
- Debugging Android bridge discovery or auth
|
||||
- Verifying chat history parity across clients
|
||||
---
|
||||
|
||||
# Android Node Connection Runbook
|
||||
# Android App (Node)
|
||||
|
||||
## Connection Runbook
|
||||
|
||||
Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||
|
||||
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
||||
|
||||
## Prerequisites
|
||||
### Prerequisites
|
||||
|
||||
- You can run the Gateway on the “master” machine.
|
||||
- Android device/emulator can reach the gateway bridge:
|
||||
@ -21,7 +23,7 @@ The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talk
|
||||
- Manual bridge host/port (fallback)
|
||||
- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH).
|
||||
|
||||
## 1) Start the Gateway (with bridge enabled)
|
||||
### 1) Start the Gateway (with bridge enabled)
|
||||
|
||||
Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`).
|
||||
|
||||
@ -37,7 +39,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host.
|
||||
- Restart the Gateway / macOS menubar app.
|
||||
|
||||
## 2) Verify discovery (optional)
|
||||
### 2) Verify discovery (optional)
|
||||
|
||||
From the gateway machine:
|
||||
|
||||
@ -47,7 +49,7 @@ dns-sd -B _clawdbot-bridge._tcp local.
|
||||
|
||||
More debugging notes: `docs/bonjour.md`.
|
||||
|
||||
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
|
||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
|
||||
@ -56,7 +58,7 @@ Android NSD/mDNS discovery won’t cross networks. If your Android node and the
|
||||
|
||||
Details and example CoreDNS config: `docs/bonjour.md`.
|
||||
|
||||
## 3) Connect from Android
|
||||
### 3) Connect from Android
|
||||
|
||||
In the Android app:
|
||||
|
||||
@ -69,7 +71,7 @@ After the first successful pairing, Android auto-reconnects on launch:
|
||||
- Manual endpoint (if enabled), otherwise
|
||||
- The last discovered bridge (best-effort).
|
||||
|
||||
## 4) Approve pairing (CLI)
|
||||
### 4) Approve pairing (CLI)
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
@ -80,7 +82,7 @@ clawdbot nodes approve <requestId>
|
||||
|
||||
Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
## 5) Verify the node is connected
|
||||
### 5) Verify the node is connected
|
||||
|
||||
- Via nodes status:
|
||||
```bash
|
||||
@ -91,7 +93,7 @@ Pairing details: `docs/gateway/pairing.md`.
|
||||
clawdbot gateway call node.list --params "{}"
|
||||
```
|
||||
|
||||
## 6) Chat + history
|
||||
### 6) Chat + history
|
||||
|
||||
The Android node’s Chat sheet uses the gateway’s **primary session key** (`main`), so history and replies are shared with WebChat and other clients:
|
||||
|
||||
@ -99,9 +101,9 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m
|
||||
- Send: `chat.send`
|
||||
- Push updates (best-effort): `chat.subscribe` → `event:"chat"`
|
||||
|
||||
## 7) Canvas + camera
|
||||
### 7) Canvas + camera
|
||||
|
||||
### Gateway Canvas Host (recommended for web content)
|
||||
#### Gateway Canvas Host (recommended for web content)
|
||||
|
||||
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host.
|
||||
|
||||
@ -32,6 +32,12 @@ Last updated: 2026-01-05
|
||||
- Canvas + actions: `WKWebView` with A2UI action bridge; accepts actions from local-network or trusted file URLs; intercepts `clawdbot://` deep links and forwards `agent.request` to the bridge.
|
||||
- Voice/talk: voice wake sends `voice.transcript` events and syncs triggers via `voicewake.get` + `voicewake.changed`; Talk Mode attaches to the bridge.
|
||||
|
||||
### Android node (`apps/android`)
|
||||
- Discovery + pairing: `BridgeDiscovery` uses mDNS/NSD to find `_clawdbot-bridge._tcp`, with manual host/port fallback.
|
||||
- Auto-connect: `NodeRuntime` restores a stored token, performs `pair-and-hello`, and reconnects to the last discovered or manual bridge.
|
||||
- Bridge runtime: `BridgeSession` owns the TCP JSONL session (`hello`/`hello-ok`, ping/pong, `req/res`, `event`, `invoke`); stores `canvasHostUrl`.
|
||||
- Commands: `NodeRuntime` executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, and chat/session events; foreground-only for canvas/camera.
|
||||
|
||||
## Components and flows
|
||||
- **Gateway (daemon)**
|
||||
- Maintains WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), and iMessage (imsg) connections.
|
||||
@ -40,7 +46,7 @@ Last updated: 2026-01-05
|
||||
- **Clients (mac app / CLI / web admin)**
|
||||
- One WS connection per client.
|
||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
||||
- On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/clawdbot-mac.md`).
|
||||
- On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/macos.md`).
|
||||
- **Agent process (Pi)**
|
||||
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
||||
- **WebChat**
|
||||
|
||||
@ -29,40 +29,40 @@ html[data-theme="auto"] {
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--bg0: #0b1a22;
|
||||
--bg1: #0a1720;
|
||||
--panel: #0e231f;
|
||||
--panel2: #102a24;
|
||||
--text: #c9eadc;
|
||||
--muted: #8ab8aa;
|
||||
--faint: #699b8d;
|
||||
--link: #6fe8c7;
|
||||
--link2: #ff7b63;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
--accent2: #5fdfa2;
|
||||
--frame-border: #6fbfa8;
|
||||
--code-bg: #091814;
|
||||
--code-fg: #d7f5e8;
|
||||
--code-accent: #5fdfa2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="auto"] {
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--bg0: #0b1a22;
|
||||
--bg1: #0a1720;
|
||||
--panel: #0e231f;
|
||||
--panel2: #102a24;
|
||||
--text: #c9eadc;
|
||||
--muted: #8ab8aa;
|
||||
--faint: #699b8d;
|
||||
--link: #6fe8c7;
|
||||
--link2: #ff7b63;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
--accent2: #5fdfa2;
|
||||
--frame-border: #6fbfa8;
|
||||
--code-bg: #091814;
|
||||
--code-fg: #d7f5e8;
|
||||
--code-accent: #5fdfa2;
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,39 +87,9 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, var(--scanline-opacity)),
|
||||
rgba(0, 0, 0, var(--scanline-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent var(--scanline-size)
|
||||
);
|
||||
opacity: 0.8;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
---
|
||||
summary: "Fix Chrome/Chromium CDP startup issues for Clawdbot browser control on Linux"
|
||||
read_when: "Browser control fails on Linux, especially with snap Chromium"
|
||||
---
|
||||
|
||||
# Browser Troubleshooting (Linux)
|
||||
|
||||
## Problem: "Failed to start Chrome CDP on port 18800"
|
||||
|
||||
@ -152,7 +152,7 @@ Example:
|
||||
|
||||
When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDBOT suppresses outbound delivery for that heartbeat.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat.
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -193,5 +193,9 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
|
||||
- WebChat: [WebChat](./webchat.md)
|
||||
- Gateway ops: [Gateway runbook](./gateway.md)
|
||||
- Cron + wakeups: [Cron + wakeups](./cron.md)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](./clawdbot-mac.md)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](./macos.md)
|
||||
- iOS node app: [iOS app](./ios.md)
|
||||
- Android node app: [Android app](./android.md)
|
||||
- Windows status: [Windows app](./windows.md)
|
||||
- Linux status: [Linux app](./linux.md)
|
||||
- Security: [Security](./security.md)
|
||||
|
||||
@ -91,18 +91,18 @@ Env var equivalent:
|
||||
|
||||
### Auth storage (OAuth + API keys)
|
||||
|
||||
Clawdbot keeps subscription OAuth tokens + API keys in the **agent auth store**:
|
||||
Clawdbot stores **OAuth credentials** in:
|
||||
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
|
||||
|
||||
Clawdbot stores **API keys** in the agent auth store:
|
||||
- `~/.clawdbot/agent/auth.json`
|
||||
|
||||
The agent directory can be overridden with:
|
||||
- `CLAWDBOT_AGENT_DIR` (preferred)
|
||||
- `PI_CODING_AGENT_DIR` (legacy)
|
||||
Overrides:
|
||||
- OAuth dir: `CLAWDBOT_OAUTH_DIR`
|
||||
- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
|
||||
|
||||
Legacy OAuth storage is still supported for migration:
|
||||
- Default: `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
|
||||
- Override: `CLAWDBOT_OAUTH_DIR`
|
||||
|
||||
On first use, Clawdbot auto‑migrates legacy `oauth.json` entries into `auth.json`.
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth.json` so the embedded
|
||||
agent can use them. `oauth.json` remains the source of truth for OAuth refresh.
|
||||
|
||||
### `identity`
|
||||
|
||||
@ -141,6 +141,9 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
- Console output can be tuned separately via:
|
||||
- `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`)
|
||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||
- Tool summaries can be redacted to avoid leaking secrets:
|
||||
- `logging.redactSensitive` (`off` | `tools`, default: `tools`)
|
||||
- `logging.redactPatterns` (array of regex strings; overrides defaults)
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -148,7 +151,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
level: "info",
|
||||
file: "/tmp/clawdbot/clawdbot.log",
|
||||
consoleLevel: "info",
|
||||
consoleStyle: "pretty"
|
||||
consoleStyle: "pretty",
|
||||
redactSensitive: "tools",
|
||||
redactPatterns: [
|
||||
// Example: override defaults with your own rules.
|
||||
"\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1",
|
||||
"/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -432,16 +441,26 @@ Default: `~/clawd`.
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with their
|
||||
own per-session workspaces under `agent.sandbox.workspaceRoot`.
|
||||
|
||||
### `agent.userTimezone`
|
||||
|
||||
Sets the user’s timezone for **system prompt context** (not for timestamps in
|
||||
message envelopes). If unset, Clawdbot uses the host timezone at runtime.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
}
|
||||
```
|
||||
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
Controls inbound/outbound prefixes.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
messagePrefix: "[clawdbot]",
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: "Europe/London"
|
||||
responsePrefix: "🦞"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -560,6 +579,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
|
||||
`agent.bash` configures background bash defaults:
|
||||
- `backgroundMs`: time before auto-background (ms, default 10000)
|
||||
|
||||
@ -26,6 +26,8 @@
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"index",
|
||||
"showcase",
|
||||
"hubs",
|
||||
"onboarding",
|
||||
"clawd",
|
||||
"faq"
|
||||
@ -70,8 +72,9 @@
|
||||
"mac/dev-setup",
|
||||
"mac/menu-bar",
|
||||
"mac/voicewake",
|
||||
"ios/connect",
|
||||
"android/connect",
|
||||
"macos",
|
||||
"ios",
|
||||
"android",
|
||||
"webchat",
|
||||
"web"
|
||||
]
|
||||
|
||||
22
docs/faq.md
22
docs/faq.md
@ -14,9 +14,9 @@ Everything lives under `~/.clawdbot/`:
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
|
||||
| `~/.clawdbot/agent/auth.json` | OAuth + API key store (Anthropic/OpenAI, etc.) |
|
||||
| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) |
|
||||
| `~/.clawdbot/agent/auth.json` | API key store |
|
||||
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
|
||||
| `~/.clawdbot/credentials/oauth.json` | Legacy OAuth store (auto‑migrated) |
|
||||
| `~/.clawdbot/sessions/` | Conversation history & state |
|
||||
| `~/.clawdbot/sessions/sessions.json` | Session metadata |
|
||||
|
||||
@ -42,10 +42,10 @@ Some features are platform-specific:
|
||||
- **CPU:** 1 core is fine for personal use
|
||||
- **Disk:** ~500MB for Clawdbot + deps, plus space for logs/media
|
||||
|
||||
The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. You can also use **Bun** instead of Node for even lower memory footprint:
|
||||
The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. For the CLI, prefer the Node runtime (most stable):
|
||||
|
||||
```bash
|
||||
bun clawdbot gateway
|
||||
pnpm clawdbot gateway
|
||||
```
|
||||
|
||||
### How do I install on Linux without Homebrew?
|
||||
@ -78,7 +78,7 @@ This creates `~/.clawdbot/clawdbot.json` with your API keys, workspace path, and
|
||||
cp -r ~/.clawdbot ~/.clawdbot-backup
|
||||
|
||||
# Remove config and credentials
|
||||
rm -rf ~/.clawdbot
|
||||
trash ~/.clawdbot
|
||||
|
||||
# Re-run onboarding
|
||||
pnpm clawdbot onboard
|
||||
@ -118,7 +118,7 @@ They're **separate billing**! An API key does NOT use your subscription.
|
||||
pnpm clawdbot login
|
||||
```
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agent/auth.json` to your server. The auth is just a JSON file.
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/credentials/oauth.json` to your server. The auth is just a JSON file.
|
||||
|
||||
### How are env vars loaded?
|
||||
|
||||
@ -148,7 +148,7 @@ Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=1500
|
||||
|
||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
||||
|
||||
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agent/auth.json` to the container.
|
||||
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/oauth.json` to the container.
|
||||
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
|
||||
3. **Tailscale** — Put both machines on your tailnet.
|
||||
|
||||
@ -229,7 +229,7 @@ Yes! The terminal QR code login works fine over SSH. For long-running operation:
|
||||
### bun binary vs Node runtime?
|
||||
|
||||
Clawdbot can run as:
|
||||
- **bun binary** — Single executable, easy distribution, auto-restarts via launchd
|
||||
- **bun binary (macOS app)** — Single executable, easy distribution, auto-restarts via launchd
|
||||
- **Node runtime** (`pnpm clawdbot gateway`) — More stable for WhatsApp
|
||||
|
||||
If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemented`, use Node instead of the bun binary. Bun's WebSocket implementation has edge cases that can break WhatsApp (Baileys).
|
||||
@ -471,7 +471,7 @@ codex --full-auto "debug why clawdbot gateway won't start"
|
||||
Linux installs use a systemd **user** service. By default, systemd stops user
|
||||
services on logout/idle, which kills the Gateway.
|
||||
|
||||
Fix:
|
||||
Onboarding attempts to enable lingering; if it’s still off, run:
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
@ -531,10 +531,10 @@ sudo systemctl disable --now clawdbot
|
||||
pkill -f "clawdbot"
|
||||
|
||||
# Remove data
|
||||
rm -rf ~/.clawdbot
|
||||
trash ~/.clawdbot
|
||||
|
||||
# Remove repo and re-clone
|
||||
rm -rf ~/clawdbot
|
||||
trash ~/clawdbot
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot && pnpm install && pnpm build
|
||||
pnpm clawdbot onboard
|
||||
|
||||
@ -182,12 +182,21 @@ Enable lingering (required so the user service survives logout/idle):
|
||||
```
|
||||
sudo loginctl enable-linger youruser
|
||||
```
|
||||
Requires sudo (writes `/var/lib/systemd/linger`).
|
||||
Onboarding runs this on Linux (may prompt for sudo; writes `/var/lib/systemd/linger`).
|
||||
Then enable the service:
|
||||
```
|
||||
systemctl --user enable --now clawdbot-gateway.service
|
||||
```
|
||||
|
||||
**Alternative (system service)** - for always-on or multi-user servers, you can
|
||||
install a systemd **system** unit instead of a user unit (no lingering needed).
|
||||
Create `/etc/systemd/system/clawdbot-gateway.service` (copy the unit above,
|
||||
switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then:
|
||||
```
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now clawdbot-gateway.service
|
||||
```
|
||||
|
||||
## Supervision (Windows scheduled task)
|
||||
- Onboarding installs a Scheduled Task named `Clawdbot Gateway` (runs on user logon).
|
||||
- Requires a logged-in user session; for headless setups use a system service or a task configured to run without a logged-in user (not shipped).
|
||||
|
||||
@ -10,10 +10,10 @@ surface anything that needs attention without spamming the user.
|
||||
|
||||
## Prompt contract
|
||||
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
||||
- If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`.
|
||||
- If nothing needs attention, the model should reply `HEARTBEAT_OK`.
|
||||
- During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at
|
||||
the **start or end** of the reply. Clawdbot strips the token and discards the
|
||||
reply if the remaining content is **≤ 30 characters**.
|
||||
reply if the remaining content is **≤ `ackMaxChars`** (default: 30).
|
||||
- If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially.
|
||||
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
|
||||
|
||||
@ -39,7 +39,8 @@ and final replies:
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
target: "last", // last | whatsapp | telegram | none
|
||||
to: "+15551234567", // optional override for whatsapp/telegram
|
||||
prompt: "HEARTBEAT" // optional override
|
||||
prompt: "HEARTBEAT", // optional override
|
||||
ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -55,6 +56,7 @@ and final replies:
|
||||
- `none`: do not deliver externally; output stays in the session (WebChat-visible).
|
||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
|
||||
## Behavior
|
||||
- Runs in the main session (`main`, or `global` when scope is global).
|
||||
|
||||
148
docs/hubs.md
Normal file
148
docs/hubs.md
Normal file
@ -0,0 +1,148 @@
|
||||
---
|
||||
summary: "Hubs that link to every Clawdbot doc"
|
||||
read_when:
|
||||
- You want a complete map of the documentation
|
||||
---
|
||||
# Docs hubs
|
||||
|
||||
Use these hubs to discover every page, including deep dives and reference docs that don’t appear in the left nav.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Index](./index.md)
|
||||
- [Onboarding](./onboarding.md)
|
||||
- [Wizard](./wizard.md)
|
||||
- [Setup](./setup.md)
|
||||
- [FAQ](./faq.md)
|
||||
- [Configuration](./configuration.md)
|
||||
- [Clawd (personal assistant)](./clawd.md)
|
||||
- [Lore](./lore.md)
|
||||
|
||||
## Installation + distribution
|
||||
|
||||
- [Docker](./docker.md)
|
||||
- [Nix](./nix.md)
|
||||
|
||||
## Core concepts
|
||||
|
||||
- [Architecture](./architecture.md)
|
||||
- [Agent runtime](./agent.md)
|
||||
- [Agent loop](./agent-loop.md)
|
||||
- [Sessions](./session.md)
|
||||
- [Sessions (alias)](./sessions.md)
|
||||
- [Session tools](./session-tool.md)
|
||||
- [Queue](./queue.md)
|
||||
- [RPC adapters](./rpc.md)
|
||||
- [TypeBox schemas](./typebox.md)
|
||||
- [Presence](./presence.md)
|
||||
- [Discovery + transports](./discovery.md)
|
||||
- [Bonjour](./bonjour.md)
|
||||
- [Surface routing](./surface.md)
|
||||
- [Groups](./groups.md)
|
||||
- [Group messages](./group-messages.md)
|
||||
|
||||
## Providers + ingress
|
||||
|
||||
- [WhatsApp](./whatsapp.md)
|
||||
- [Telegram](./telegram.md)
|
||||
- [Telegram (grammY notes)](./grammy.md)
|
||||
- [Slack](./slack.md)
|
||||
- [Discord](./discord.md)
|
||||
- [Signal](./signal.md)
|
||||
- [iMessage](./imessage.md)
|
||||
- [WebChat](./webchat.md)
|
||||
- [Webhooks](./webhook.md)
|
||||
- [Gmail Pub/Sub](./gmail-pubsub.md)
|
||||
|
||||
## Gateway + operations
|
||||
|
||||
- [Gateway runbook](./gateway.md)
|
||||
- [Gateway pairing](./gateway/pairing.md)
|
||||
- [Gateway lock](./gateway-lock.md)
|
||||
- [Background process](./background-process.md)
|
||||
- [Health](./health.md)
|
||||
- [Heartbeat](./heartbeat.md)
|
||||
- [Doctor](./doctor.md)
|
||||
- [Logging](./logging.md)
|
||||
- [Dashboard](./dashboard.md)
|
||||
- [Control UI](./control-ui.md)
|
||||
- [Control API (legacy)](./control-api.md)
|
||||
- [Remote access](./remote.md)
|
||||
- [Remote gateway README](./remote-gateway-readme.md)
|
||||
- [Tailscale](./tailscale.md)
|
||||
- [Security](./security.md)
|
||||
- [Troubleshooting](./troubleshooting.md)
|
||||
|
||||
## Tools + automation
|
||||
|
||||
- [Tools surface](./tools.md)
|
||||
- [Bash tool](./bash.md)
|
||||
- [Elevated mode](./elevated.md)
|
||||
- [Cron + wakeups](./cron.md)
|
||||
- [Thinking + verbose](./thinking.md)
|
||||
- [Models](./models.md)
|
||||
- [Agent send CLI](./agent-send.md)
|
||||
- [Terminal UI](./tui.md)
|
||||
- [Browser control](./browser.md)
|
||||
- [Browser (Linux troubleshooting)](./browser-linux-troubleshooting.md)
|
||||
|
||||
## Nodes, media, voice
|
||||
|
||||
- [Nodes overview](./nodes.md)
|
||||
- [Camera](./camera.md)
|
||||
- [Images](./images.md)
|
||||
- [Audio](./audio.md)
|
||||
- [Location command](./location-command.md)
|
||||
- [Voice wake](./voicewake.md)
|
||||
- [Talk mode](./talk.md)
|
||||
|
||||
## Platforms
|
||||
|
||||
- [macOS app overview](./macos.md)
|
||||
- [macOS dev setup](./mac/dev-setup.md)
|
||||
- [macOS menu bar](./mac/menu-bar.md)
|
||||
- [macOS voice wake](./mac/voicewake.md)
|
||||
- [macOS voice overlay](./mac/voice-overlay.md)
|
||||
- [macOS WebChat](./mac/webchat.md)
|
||||
- [macOS Canvas](./mac/canvas.md)
|
||||
- [macOS child process](./mac/child-process.md)
|
||||
- [macOS health](./mac/health.md)
|
||||
- [macOS icon](./mac/icon.md)
|
||||
- [macOS logging](./mac/logging.md)
|
||||
- [macOS permissions](./mac/permissions.md)
|
||||
- [macOS remote](./mac/remote.md)
|
||||
- [macOS signing](./mac/signing.md)
|
||||
- [macOS release](./mac/release.md)
|
||||
- [macOS bun gateway](./mac/bun.md)
|
||||
- [macOS XPC](./mac/xpc.md)
|
||||
- [macOS skills](./mac/skills.md)
|
||||
- [macOS Peekaboo plan](./mac/peekaboo.md)
|
||||
- [iOS node](./ios.md)
|
||||
- [Android node](./android.md)
|
||||
- [Windows app](./windows.md)
|
||||
- [Linux app](./linux.md)
|
||||
- [Web surfaces](./web.md)
|
||||
|
||||
## Workspace + templates
|
||||
|
||||
- [Skills](./skills.md)
|
||||
- [Skills config](./skills-config.md)
|
||||
- [Default AGENTS](./AGENTS.default.md)
|
||||
- [Templates: AGENTS](./templates/AGENTS.md)
|
||||
- [Templates: BOOTSTRAP](./templates/BOOTSTRAP.md)
|
||||
- [Templates: IDENTITY](./templates/IDENTITY.md)
|
||||
- [Templates: SOUL](./templates/SOUL.md)
|
||||
- [Templates: TOOLS](./templates/TOOLS.md)
|
||||
- [Templates: USER](./templates/USER.md)
|
||||
|
||||
## Experiments + proposals
|
||||
|
||||
- [Onboarding config protocol](./onboarding-config-protocol.md)
|
||||
- [Research: memory](./research/memory.md)
|
||||
- [Proposal: model config](./proposals/model-config.md)
|
||||
|
||||
## Testing + release
|
||||
|
||||
- [Testing](./test.md)
|
||||
- [Release checklist](./RELEASING.md)
|
||||
- [Device models](./device-models.md)
|
||||
@ -42,7 +42,8 @@ WhatsApp / Telegram / Discord
|
||||
├─ CLI (clawdbot …)
|
||||
├─ Chat UI (SwiftUI)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node via Bridge + pairing
|
||||
├─ iOS node via Bridge + pairing
|
||||
└─ Android node via Bridge + pairing
|
||||
```
|
||||
|
||||
Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns provider connections and the WebSocket control plane.
|
||||
@ -70,6 +71,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
- 🎤 **Voice notes** — Optional transcription hook
|
||||
- 🖥️ **WebChat + macOS app** — Local UI + menu bar companion for ops and voice wake
|
||||
- 📱 **iOS node** — Pairs as a node and exposes a Canvas surface
|
||||
- 📱 **Android node** — Pairs as a node and exposes Canvas + Chat + Camera
|
||||
|
||||
Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path.
|
||||
|
||||
@ -126,6 +128,7 @@ Example:
|
||||
## Docs
|
||||
|
||||
- Start here:
|
||||
- [Docs hubs (all pages linked)](./hubs.md)
|
||||
- [FAQ](./faq.md) ← *common questions answered*
|
||||
- [Configuration](./configuration.md)
|
||||
- [Nix mode](./nix.md)
|
||||
@ -149,6 +152,12 @@ Example:
|
||||
- [WhatsApp group messages](./group-messages.md)
|
||||
- [Media: images](./images.md)
|
||||
- [Media: audio](./audio.md)
|
||||
- Companion apps:
|
||||
- [macOS app](./macos.md)
|
||||
- [iOS app](./ios.md)
|
||||
- [Android app](./android.md)
|
||||
- [Windows app](./windows.md)
|
||||
- [Linux app](./linux.md)
|
||||
- Ops and safety:
|
||||
- [Sessions](./session.md)
|
||||
- [Cron + wakeups](./cron.md)
|
||||
|
||||
@ -1,17 +1,182 @@
|
||||
---
|
||||
summary: "Plan for an iOS voice + canvas node that connects via a secure Bonjour-discovered macOS bridge"
|
||||
summary: "iOS app (node): architecture + connection runbook"
|
||||
read_when:
|
||||
- Pairing or reconnecting the iOS node
|
||||
- Debugging iOS bridge discovery or auth
|
||||
- Sending screen/canvas commands to iOS
|
||||
- Designing iOS node + gateway integration
|
||||
- Extending the Gateway protocol for node/canvas commands
|
||||
- Implementing Bonjour pairing or transport security
|
||||
---
|
||||
# iOS Node (internal) — Voice Trigger + Canvas
|
||||
# iOS App (Node)
|
||||
|
||||
Status: prototype implemented (internal) · Date: 2025-12-13
|
||||
|
||||
Runbook (how to connect/pair + drive Canvas): `docs/ios/connect.md`
|
||||
## Connection Runbook
|
||||
|
||||
## Goals
|
||||
This is the practical “how do I connect the iOS node” guide:
|
||||
|
||||
**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||
|
||||
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). The iOS node talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- You can run the Gateway on the “master” machine.
|
||||
- iOS node app can reach the gateway bridge:
|
||||
- Same LAN with Bonjour/mDNS, **or**
|
||||
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
|
||||
- Manual bridge host/port (fallback)
|
||||
- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH).
|
||||
|
||||
### 1) Start the Gateway (with bridge enabled)
|
||||
|
||||
Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`).
|
||||
|
||||
```bash
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
Confirm in logs you see something like:
|
||||
- `bridge listening on tcp://0.0.0.0:18790 (node)`
|
||||
|
||||
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
||||
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host.
|
||||
- Restart the Gateway / macOS menubar app.
|
||||
|
||||
### 2) Verify Bonjour discovery (optional but recommended)
|
||||
|
||||
From the gateway machine:
|
||||
|
||||
```bash
|
||||
dns-sd -B _clawdbot-bridge._tcp local.
|
||||
```
|
||||
|
||||
You should see your gateway advertising `_clawdbot-bridge._tcp`.
|
||||
|
||||
If browse works, but the iOS node can’t connect, try resolving one instance:
|
||||
|
||||
```bash
|
||||
dns-sd -L "<instance name>" _clawdbot-bridge._tcp local.
|
||||
```
|
||||
|
||||
More debugging notes: `docs/bonjour.md`.
|
||||
|
||||
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
|
||||
If the iOS node and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
|
||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records.
|
||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||
|
||||
Details and example CoreDNS config: `docs/bonjour.md`.
|
||||
|
||||
### 3) Connect from the iOS node app
|
||||
|
||||
In the iOS node app:
|
||||
- Pick the discovered bridge (or hit refresh).
|
||||
- If not paired yet, it will initiate pairing automatically.
|
||||
- After the first successful pairing, it will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present.
|
||||
|
||||
#### Connection indicator (always visible)
|
||||
|
||||
The Settings tab icon shows a small status dot:
|
||||
- **Green**: connected to the bridge
|
||||
- **Yellow**: connecting (subtle pulse)
|
||||
- **Red**: not connected / error
|
||||
|
||||
### 4) Approve pairing (CLI)
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
```
|
||||
|
||||
Approve the request:
|
||||
|
||||
```bash
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
After approval, the iOS node receives/stores the token and reconnects authenticated.
|
||||
|
||||
Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
### 5) Verify the node is connected
|
||||
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect.
|
||||
- Via nodes status (paired + connected):
|
||||
```bash
|
||||
clawdbot nodes status
|
||||
```
|
||||
- Via Gateway (paired + connected):
|
||||
```bash
|
||||
clawdbot gateway call node.list --params "{}"
|
||||
```
|
||||
- Via Gateway presence (legacy-ish, still useful):
|
||||
```bash
|
||||
clawdbot gateway call system-presence --params "{}"
|
||||
```
|
||||
Look for the node `instanceId` (often a UUID).
|
||||
|
||||
### 6) Drive the iOS Canvas (draw / snapshot)
|
||||
|
||||
The iOS node runs a WKWebView “Canvas” scaffold which exposes:
|
||||
- `window.__clawdbot.canvas`
|
||||
- `window.__clawdbot.ctx` (2D context)
|
||||
- `window.__clawdbot.setStatus(title, subtitle)`
|
||||
|
||||
#### Gateway Canvas Host (recommended for web content)
|
||||
|
||||
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host.
|
||||
|
||||
Note: nodes always use the standalone canvas host on `canvasHost.port` (default `18793`), bound to the bridge interface.
|
||||
|
||||
1) Create `~/clawd/canvas/index.html` on the gateway host.
|
||||
|
||||
2) Navigate the node to it (LAN):
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/__clawdbot__/canvas/"}'
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The server injects a live-reload client into HTML and reloads on file changes.
|
||||
- A2UI is hosted on the same canvas host at `http://<gateway-host>:18793/__clawdbot__/a2ui/`.
|
||||
- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18793/__clawdbot__/canvas/`.
|
||||
- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config.
|
||||
|
||||
#### Draw with `canvas.eval`
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node "iOS Node" --command canvas.eval --params "$(cat <<'JSON'
|
||||
{"javaScript":"(() => { const {ctx,setStatus} = window.__clawdbot; setStatus('Drawing','…'); ctx.clearRect(0,0,innerWidth,innerHeight); ctx.lineWidth=6; ctx.strokeStyle='#ff2d55'; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(innerWidth-40, innerHeight-40); ctx.stroke(); setStatus(null,null); return 'ok'; })()"}
|
||||
JSON
|
||||
)"
|
||||
```
|
||||
|
||||
#### Snapshot with `canvas.snapshot`
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node 192.168.0.88 --command canvas.snapshot --params '{"maxWidth":900}'
|
||||
```
|
||||
|
||||
The response includes `{ format, base64 }` image data (default `format="jpeg"`; pass `{"format":"png"}` when you specifically need lossless PNG).
|
||||
|
||||
### Common gotchas
|
||||
|
||||
- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground).
|
||||
- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page.
|
||||
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
||||
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
||||
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request.
|
||||
- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
|
||||
|
||||
## Design + Architecture
|
||||
|
||||
### Goals
|
||||
- Build an **iOS app** that acts as a **remote node** for Clawdbot:
|
||||
- **Voice trigger** (wake-word / always-listening intent) that forwards transcripts to the Gateway `agent` method.
|
||||
- **Canvas** surface that the agent can control: navigate, draw/render, evaluate JS, snapshot.
|
||||
@ -28,13 +193,13 @@ Non-goals (v1):
|
||||
- Supporting arbitrary third-party “plugins” on iOS.
|
||||
- Perfect App Store compliance; this is **internal-only** initially.
|
||||
|
||||
## Current repo reality (constraints we respect)
|
||||
### Current repo reality (constraints we respect)
|
||||
- The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDBOT_GATEWAY_TOKEN`.
|
||||
- The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://<lanHost>:18793/__clawdbot__/canvas/` and auto-reload on file changes (`docs/configuration.md`).
|
||||
- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`).
|
||||
- Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`).
|
||||
|
||||
## Recommended topology (B): Gateway-owned Bridge + loopback Gateway
|
||||
### Recommended topology (B): Gateway-owned Bridge + loopback Gateway
|
||||
Keep the Node gateway loopback-only; expose a dedicated **gateway-owned bridge** to the LAN/tailnet.
|
||||
|
||||
**iOS App** ⇄ (TLS + pairing) ⇄ **Bridge (in gateway)** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`)
|
||||
@ -44,12 +209,12 @@ Why:
|
||||
- Centralizes auth, rate limiting, and allowlisting in the bridge.
|
||||
- Lets us unify “canvas node” semantics across mac + iOS without exposing raw gateway methods.
|
||||
|
||||
## Security plan (internal, but still robust)
|
||||
### Transport
|
||||
### Security plan (internal, but still robust)
|
||||
#### Transport
|
||||
- **Current (v0):** bridge is a LAN-facing **TCP** listener with token-based auth after pairing.
|
||||
- **Next:** wrap the bridge in **TLS** and prefer key-pinned or mTLS-like auth after pairing.
|
||||
|
||||
### Pairing
|
||||
#### Pairing
|
||||
- Bonjour discovery shows a candidate “Clawdbot Bridge” on the LAN.
|
||||
- First connection:
|
||||
1) iOS generates a keypair (Secure Enclave if available).
|
||||
@ -62,7 +227,7 @@ Why:
|
||||
- Subsequent connections:
|
||||
- The bridge requires the paired identity. Unpaired clients get a structured “not paired” error and no access.
|
||||
|
||||
#### Gateway-owned pairing (Option B details)
|
||||
##### Gateway-owned pairing (Option B details)
|
||||
Pairing decisions must be owned by the Gateway (`clawd` / Node) so nodes can be approved without the macOS app running.
|
||||
|
||||
Key idea:
|
||||
@ -79,7 +244,7 @@ CLI (headless approvals):
|
||||
- `clawdbot nodes approve <requestId>`
|
||||
- `clawdbot nodes reject <requestId>`
|
||||
|
||||
### Authorization / scope control (bridge-side ACL)
|
||||
#### Authorization / scope control (bridge-side ACL)
|
||||
The bridge must not be a raw proxy to every gateway method.
|
||||
|
||||
- Allow by default:
|
||||
@ -93,15 +258,15 @@ The bridge must not be a raw proxy to every gateway method.
|
||||
- voice forwards per minute
|
||||
- snapshot frequency / payload size
|
||||
|
||||
## Protocol unification: add “node/canvas” to Gateway protocol
|
||||
### Principle
|
||||
### Protocol unification: add “node/canvas” to Gateway protocol
|
||||
#### Principle
|
||||
Unify mac Canvas + iOS Canvas under a single conceptual surface:
|
||||
- The agent talks to the Gateway using a stable method set (typed protocol).
|
||||
- The Gateway routes node-targeted requests to:
|
||||
- local mac Canvas implementation, or
|
||||
- remote iOS node via the bridge
|
||||
|
||||
### Minimal protocol additions (v1)
|
||||
#### Minimal protocol additions (v1)
|
||||
Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
|
||||
|
||||
**Identity**
|
||||
@ -117,7 +282,7 @@ Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
|
||||
- `node.event` → async node status/errors
|
||||
- e.g. background/foreground transitions, voice availability, canvas availability
|
||||
|
||||
### Node command set (canvas)
|
||||
#### Node command set (canvas)
|
||||
These are values for `node.invoke.command`:
|
||||
- `canvas.present` / `canvas.hide`
|
||||
- `canvas.navigate` with `{ url }` (loads a URL; use `""` or `"/"` to return to the default scaffold)
|
||||
@ -133,7 +298,7 @@ Result pattern:
|
||||
- Request is a standard `req/res` with `ok` / `error`.
|
||||
- Long operations (loads, streaming drawing, etc.) may also emit `node.event` progress.
|
||||
|
||||
#### Current (implemented)
|
||||
##### Current (implemented)
|
||||
As of 2025-12-13, the Gateway supports `node.invoke` for bridge-connected nodes.
|
||||
|
||||
Example: draw a diagonal line on the iOS Canvas:
|
||||
@ -199,38 +364,9 @@ open Clawdbot.xcodeproj
|
||||
- Credentials:
|
||||
- Keychain (paired identity + bridge trust anchor)
|
||||
|
||||
### macOS
|
||||
- Keep current Canvas root (already implemented):
|
||||
- `~/Library/Application Support/Clawdbot/canvas/<session>/...`
|
||||
- Bridge state:
|
||||
- No local pairing store (pairing is gateway-owned).
|
||||
- Any local bridge-only state should remain private under Application Support.
|
||||
## Related docs
|
||||
|
||||
### Gateway (node)
|
||||
- Pairing (source of truth):
|
||||
- `~/.clawdbot/nodes/paired.json`
|
||||
- `~/.clawdbot/nodes/pending.json` (or `pending/*.json` for auditability)
|
||||
|
||||
## Rollout plan (phased)
|
||||
1) **Bridge discovery + pairing (mac + iOS)**
|
||||
- Bonjour browse + resolve
|
||||
- Approve prompt on mac
|
||||
- Persist pairing in Keychain/App Support
|
||||
2) **Voice-only node**
|
||||
- iOS voice wake toggle
|
||||
- Forward transcript to Gateway `agent` via bridge
|
||||
- Presence beacons via `system-event` (or node.event)
|
||||
3) **Protocol additions for nodes**
|
||||
- Add `node.list` / `node.invoke` / `node.event` to Gateway
|
||||
- Implement bridge routing + ACLs
|
||||
4) **iOS canvas**
|
||||
- WKWebView canvas surface
|
||||
- `canvas.navigate/eval/snapshot`
|
||||
- Background fast-fail for `canvas.*`
|
||||
5) **Unify mac Canvas under the same node.invoke**
|
||||
- Keep existing implementation, but expose it through the unified protocol path so the agent uses one API.
|
||||
|
||||
## Open questions
|
||||
- Should `connect.params.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.)
|
||||
- Do we want a “permissions” model per node (voice only vs voice+canvas) at pairing time?
|
||||
- Should loading arbitrary websites via `canvas.navigate` allow any https URL, or enforce an allowlist to reduce risk?
|
||||
- `docs/gateway.md` (gateway runbook)
|
||||
- `docs/gateway/pairing.md` (approval + storage)
|
||||
- `docs/bonjour.md` (discovery debugging)
|
||||
- `docs/discovery.md` (LAN vs tailnet vs SSH)
|
||||
@ -1,177 +0,0 @@
|
||||
---
|
||||
summary: "Runbook: connect/pair the iOS node to a Clawdbot Gateway and drive its Canvas"
|
||||
read_when:
|
||||
- Pairing or reconnecting the iOS node
|
||||
- Debugging iOS bridge discovery or auth
|
||||
- Sending screen/canvas commands to iOS
|
||||
---
|
||||
|
||||
# iOS Node Connection Runbook
|
||||
|
||||
This is the practical “how do I connect the iOS node” guide:
|
||||
|
||||
**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||
|
||||
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). The iOS node talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You can run the Gateway on the “master” machine.
|
||||
- iOS node app can reach the gateway bridge:
|
||||
- Same LAN with Bonjour/mDNS, **or**
|
||||
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
|
||||
- Manual bridge host/port (fallback)
|
||||
- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH).
|
||||
|
||||
## 1) Start the Gateway (with bridge enabled)
|
||||
|
||||
Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`).
|
||||
|
||||
```bash
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
Confirm in logs you see something like:
|
||||
- `bridge listening on tcp://0.0.0.0:18790 (node)`
|
||||
|
||||
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
||||
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host.
|
||||
- Restart the Gateway / macOS menubar app.
|
||||
|
||||
## 2) Verify Bonjour discovery (optional but recommended)
|
||||
|
||||
From the gateway machine:
|
||||
|
||||
```bash
|
||||
dns-sd -B _clawdbot-bridge._tcp local.
|
||||
```
|
||||
|
||||
You should see your gateway advertising `_clawdbot-bridge._tcp`.
|
||||
|
||||
If browse works, but the iOS node can’t connect, try resolving one instance:
|
||||
|
||||
```bash
|
||||
dns-sd -L "<instance name>" _clawdbot-bridge._tcp local.
|
||||
```
|
||||
|
||||
More debugging notes: `docs/bonjour.md`.
|
||||
|
||||
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||
|
||||
If the iOS node and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
|
||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records.
|
||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||
|
||||
Details and example CoreDNS config: `docs/bonjour.md`.
|
||||
|
||||
## 3) Connect from the iOS node app
|
||||
|
||||
In the iOS node app:
|
||||
- Pick the discovered bridge (or hit refresh).
|
||||
- If not paired yet, it will initiate pairing automatically.
|
||||
- After the first successful pairing, it will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present.
|
||||
|
||||
### Connection indicator (always visible)
|
||||
|
||||
The Settings tab icon shows a small status dot:
|
||||
- **Green**: connected to the bridge
|
||||
- **Yellow**: connecting (subtle pulse)
|
||||
- **Red**: not connected / error
|
||||
|
||||
## 4) Approve pairing (CLI)
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
```
|
||||
|
||||
Approve the request:
|
||||
|
||||
```bash
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
After approval, the iOS node receives/stores the token and reconnects authenticated.
|
||||
|
||||
Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
## 5) Verify the node is connected
|
||||
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect.
|
||||
- Via nodes status (paired + connected):
|
||||
```bash
|
||||
clawdbot nodes status
|
||||
```
|
||||
- Via Gateway (paired + connected):
|
||||
```bash
|
||||
clawdbot gateway call node.list --params "{}"
|
||||
```
|
||||
- Via Gateway presence (legacy-ish, still useful):
|
||||
```bash
|
||||
clawdbot gateway call system-presence --params "{}"
|
||||
```
|
||||
Look for the node `instanceId` (often a UUID).
|
||||
|
||||
## 6) Drive the iOS Canvas (draw / snapshot)
|
||||
|
||||
The iOS node runs a WKWebView “Canvas” scaffold which exposes:
|
||||
- `window.__clawdbot.canvas`
|
||||
- `window.__clawdbot.ctx` (2D context)
|
||||
- `window.__clawdbot.setStatus(title, subtitle)`
|
||||
|
||||
### Gateway Canvas Host (recommended for web content)
|
||||
|
||||
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host.
|
||||
|
||||
Note: nodes always use the standalone canvas host on `canvasHost.port` (default `18793`), bound to the bridge interface.
|
||||
|
||||
1) Create `~/clawd/canvas/index.html` on the gateway host.
|
||||
|
||||
2) Navigate the node to it (LAN):
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/__clawdbot__/canvas/"}'
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The server injects a live-reload client into HTML and reloads on file changes.
|
||||
- A2UI is hosted on the same canvas host at `http://<gateway-host>:18793/__clawdbot__/a2ui/`.
|
||||
- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18793/__clawdbot__/canvas/`.
|
||||
- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config.
|
||||
|
||||
### Draw with `canvas.eval`
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node "iOS Node" --command canvas.eval --params "$(cat <<'JSON'
|
||||
{"javaScript":"(() => { const {ctx,setStatus} = window.__clawdbot; setStatus('Drawing','…'); ctx.clearRect(0,0,innerWidth,innerHeight); ctx.lineWidth=6; ctx.strokeStyle='#ff2d55'; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(innerWidth-40, innerHeight-40); ctx.stroke(); setStatus(null,null); return 'ok'; })()"}
|
||||
JSON
|
||||
)"
|
||||
```
|
||||
|
||||
### Snapshot with `canvas.snapshot`
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node 192.168.0.88 --command canvas.snapshot --params '{"maxWidth":900}'
|
||||
```
|
||||
|
||||
The response includes `{ format, base64 }` image data (default `format="jpeg"`; pass `{"format":"png"}` when you specifically need lossless PNG).
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground).
|
||||
- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page.
|
||||
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
||||
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
||||
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request.
|
||||
- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
|
||||
|
||||
## Related docs
|
||||
|
||||
- `docs/ios/spec.md` (design + architecture)
|
||||
- `docs/gateway.md` (gateway runbook)
|
||||
- `docs/gateway/pairing.md` (approval + storage)
|
||||
- `docs/bonjour.md` (discovery debugging)
|
||||
- `docs/discovery.md` (LAN vs tailnet vs SSH)
|
||||
11
docs/linux.md
Normal file
11
docs/linux.md
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
summary: "Linux app status + contribution call"
|
||||
read_when:
|
||||
- Looking for Linux companion app status
|
||||
- Planning platform coverage or contributions
|
||||
---
|
||||
# Linux App
|
||||
|
||||
Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node runs.
|
||||
|
||||
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
|
||||
@ -42,6 +42,17 @@ You can tune console verbosity independently via:
|
||||
- `logging.consoleLevel` (default `info`)
|
||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||
|
||||
## Tool summary redaction
|
||||
|
||||
Verbose tool summaries (e.g. `🛠️ bash: ...`) can mask sensitive tokens before they hit the
|
||||
console stream. This is **tools-only** and does not alter file logs.
|
||||
|
||||
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
|
||||
- `logging.redactPatterns`: array of regex strings (overrides defaults)
|
||||
- Use raw regex strings (auto `gi`), or `/pattern/flags` if you need custom flags.
|
||||
- Matches are masked by keeping the first 6 + last 4 chars (length >= 18), otherwise `***`.
|
||||
- Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, and popular token prefixes.
|
||||
|
||||
## Gateway WebSocket logs
|
||||
|
||||
The gateway prints WebSocket protocol logs in two modes:
|
||||
|
||||
@ -81,7 +81,7 @@ Canvas is exposed via the Gateway **node bridge**, so the agent can:
|
||||
This should be modeled after `WebChatManager`/`WebChatSwiftUIWindowController` but targeting `clawdbot-canvas://…` URLs.
|
||||
|
||||
Related:
|
||||
- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/clawdbot-mac.md`.
|
||||
- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/macos.md`.
|
||||
|
||||
## Agent commands (current)
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ Peekaboo’s privileged execution moved from “CLI → XPC helper” to “CLI
|
||||
- It lets us piggyback on **either** Peekaboo.app’s permissions **or** Clawdbot.app’s permissions (whichever is running).
|
||||
- It avoids “two apps with two TCC bubbles” unless needed.
|
||||
|
||||
Reference (Peekaboo submodule): `docs/bridge-host.md`.
|
||||
Reference (Peekaboo submodule): `Peekaboo/docs/bridge-host.md`.
|
||||
|
||||
## Architecture
|
||||
### Processes
|
||||
|
||||
@ -94,7 +94,7 @@ Notes:
|
||||
- In remote mode, Clawdbot will use the configured remote tunnel/endpoint.
|
||||
|
||||
## Build & dev workflow (native)
|
||||
- `cd native && swift build` (debug) / `swift build -c release`.
|
||||
- `cd apps/macos && swift build` (debug) / `swift build -c release`.
|
||||
- Run app for dev: `swift run Clawdbot` (or Xcode scheme).
|
||||
- Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway).
|
||||
- Tests: add Swift Testing suites under `apps/macos/Tests`.
|
||||
@ -1,3 +1,8 @@
|
||||
---
|
||||
summary: "RPC protocol notes for onboarding wizard and config schema"
|
||||
read_when: "Changing onboarding wizard steps or config schema endpoints"
|
||||
---
|
||||
|
||||
# Onboarding + Config Protocol
|
||||
|
||||
Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
|
||||
|
||||
@ -19,7 +19,7 @@ This doc describes the intended **first-run onboarding** for Clawdbot. The goal
|
||||
|
||||
First question: where does the **Gateway** run?
|
||||
|
||||
- **Local (this Mac):** onboarding can run OAuth flows and write the Clawdbot auth store locally.
|
||||
- **Local (this Mac):** onboarding can run OAuth flows and write OAuth credentials locally.
|
||||
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
|
||||
|
||||
Gateway auth tip:
|
||||
@ -38,10 +38,10 @@ The macOS app should:
|
||||
- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
|
||||
- Ask the user to paste the `code#state` value.
|
||||
- Exchange it for tokens and write credentials to:
|
||||
- `~/.clawdbot/agent/auth.json` (file mode `0600`, directory mode `0700`)
|
||||
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
|
||||
|
||||
Why this location matters: it’s the Clawdbot-owned auth store (OAuth + API keys).
|
||||
Clawdbot auto-migrates legacy OAuth tokens from `~/.clawdbot/credentials/oauth.json` (and older pi/Claude locations) into `auth.json` on first use.
|
||||
Why this location matters: it’s the Clawdbot-owned OAuth store.
|
||||
Clawdbot also imports `oauth.json` into the agent auth store (`~/.clawdbot/agent/auth.json`) on first use.
|
||||
|
||||
### Recommended: OAuth (OpenAI Codex)
|
||||
|
||||
@ -49,7 +49,7 @@ The macOS app should:
|
||||
- Start the OpenAI Codex OAuth (PKCE) flow in the user’s browser.
|
||||
- Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible.
|
||||
- If the callback fails, prompt the user to paste the redirect URL or code.
|
||||
- Store credentials in `~/.clawdbot/agent/auth.json` (same auth store as Anthropic).
|
||||
- Store credentials in `~/.clawdbot/credentials/oauth.json` (same OAuth store as Anthropic).
|
||||
|
||||
### Alternative: API key (instructions only)
|
||||
|
||||
@ -102,7 +102,7 @@ Once setup is complete, the user can switch to the normal chat (`main`) via the
|
||||
|
||||
We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace:
|
||||
|
||||
- Workspace is created implicitly (default `~/.clawdbot/workspace`) when local is selected,
|
||||
- Workspace is created implicitly (default `~/clawd`, configurable via `agent.workspace`) when local is selected,
|
||||
but only if the folder is empty or already contains `AGENTS.md`.
|
||||
- Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`.
|
||||
- `BOOTSTRAP.md` tells the agent to keep it conversational:
|
||||
@ -131,7 +131,7 @@ The workspace is created automatically as part of agent bootstrap (no dedicated
|
||||
Recommendation: treat the workspace as the agent’s “memory” and make it a git repo (ideally private) so identity + memories are backed up:
|
||||
|
||||
```bash
|
||||
cd ~/.clawdbot/workspace
|
||||
cd ~/clawd
|
||||
git init
|
||||
git add AGENTS.md
|
||||
git commit -m "Add agent workspace"
|
||||
@ -148,12 +148,12 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored
|
||||
|
||||
For now, remote onboarding should:
|
||||
- explain why OAuth isn't shown
|
||||
- point the user at the credential location (`~/.clawdbot/agent/auth.json`) and the workspace location on the gateway host
|
||||
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host
|
||||
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
|
||||
|
||||
### Manual credential setup
|
||||
|
||||
On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format:
|
||||
On the gateway host, create `~/.clawdbot/credentials/oauth.json` with this exact format:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -162,7 +162,7 @@ On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format
|
||||
}
|
||||
```
|
||||
|
||||
Set permissions: `chmod 600 ~/.clawdbot/agent/auth.json`
|
||||
Set permissions: `chmod 600 ~/.clawdbot/credentials/oauth.json`
|
||||
|
||||
**Note:** Clawdbot auto-imports from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
|
||||
|
||||
@ -177,8 +177,8 @@ cat ~/.claude/.credentials.json | jq '{
|
||||
refresh: .claudeAiOauth.refreshToken,
|
||||
expires: .claudeAiOauth.expiresAt
|
||||
}
|
||||
}' > ~/.clawdbot/agent/auth.json
|
||||
chmod 600 ~/.clawdbot/agent/auth.json
|
||||
}' > ~/.clawdbot/credentials/oauth.json
|
||||
chmod 600 ~/.clawdbot/credentials/oauth.json
|
||||
```
|
||||
|
||||
| Claude Code field | Clawdbot field |
|
||||
|
||||
146
docs/proposals/model-config.md
Normal file
146
docs/proposals/model-config.md
Normal file
@ -0,0 +1,146 @@
|
||||
---
|
||||
summary: "Proposal: model config, auth profiles, and fallback behavior"
|
||||
read_when:
|
||||
- Designing model selection, auth profiles, or fallback behavior
|
||||
- Migrating model config schema
|
||||
---
|
||||
|
||||
# Model config proposal
|
||||
|
||||
Goals
|
||||
- Multi OAuth + multi API key per provider
|
||||
- Model selection via `/model` with sensible fallback
|
||||
- Global (not per-session) fallback logic
|
||||
- Keep last-known-good auth profile when switching models
|
||||
- Profile override only when explicitly requested
|
||||
- Image routing override only when explicitly configured
|
||||
|
||||
Non-goals (v1)
|
||||
- Auto-discovery of provider capabilities beyond catalog input tags
|
||||
- Per-model auth profile order (see open questions)
|
||||
|
||||
## Proposed config shape
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"profiles": {
|
||||
"anthropic:default": {
|
||||
"provider": "anthropic",
|
||||
"mode": "oauth"
|
||||
},
|
||||
"anthropic:work": {
|
||||
"provider": "anthropic",
|
||||
"mode": "api_key"
|
||||
},
|
||||
"openai:default": {
|
||||
"provider": "openai",
|
||||
"mode": "oauth"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"anthropic": ["anthropic:default", "anthropic:work"],
|
||||
"openai": ["openai:default"]
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": {
|
||||
"alias": "Opus"
|
||||
},
|
||||
"openai/gpt-5.2": {
|
||||
"alias": "gpt52"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5",
|
||||
"fallbacks": ["openai/gpt-5.2"]
|
||||
},
|
||||
"imageModel": {
|
||||
"primary": "openai/gpt-5.2",
|
||||
"fallbacks": ["anthropic/claude-opus-4-5"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes
|
||||
- Canonical model keys are full `provider/model`.
|
||||
- `alias` optional; used by `/model` resolution.
|
||||
- `auth.profiles` is keyed. Default CLI login creates `provider:default`.
|
||||
- `auth.order[provider]` controls rotation order for that provider.
|
||||
|
||||
## CLI / UX
|
||||
|
||||
Login
|
||||
- `clawdbot login anthropic` → create/replace `anthropic:default`.
|
||||
- `clawdbot login anthropic --profile work` → create/replace `anthropic:work`.
|
||||
|
||||
Model selection
|
||||
- `/model Opus` → resolve alias to full id.
|
||||
- `/model anthropic/claude-opus-4-5` → explicit.
|
||||
- Optional: `/model Opus@anthropic:work` (explicit profile override for session only).
|
||||
|
||||
Model listing
|
||||
- `/model` list shows:
|
||||
- model id
|
||||
- alias
|
||||
- provider
|
||||
- auth order (from `auth.order`)
|
||||
- auth source for the current provider (env/auth.json/models.json)
|
||||
|
||||
## Fallback behavior (global)
|
||||
|
||||
Fallback list
|
||||
- Use `agent.model.fallbacks` globally.
|
||||
- No per-session fallback list; last-known-good is per-session but uses global ordering.
|
||||
|
||||
Auth profile rotation
|
||||
- If provider auth error (401/403/invalid refresh):
|
||||
- advance to next profile in `auth.order[provider]`.
|
||||
- if all fail, fall back to next model.
|
||||
|
||||
Rate limiting
|
||||
- If rate limit / quota error:
|
||||
- rotate auth profile first (same provider)
|
||||
- if still failing, fall back to next model.
|
||||
|
||||
Model not found / capability mismatch
|
||||
- immediate model fallback.
|
||||
|
||||
## Image routing
|
||||
|
||||
Rule
|
||||
- Only use `agent.imageModel` when explicitly configured.
|
||||
- If `agent.imageModel` is configured and the current text model lacks image input, use it.
|
||||
|
||||
Support detection
|
||||
- From model catalog `input` tags when available (e.g. `image` in models.json).
|
||||
- If unknown: treat as text-only and use `agent.imageModel`.
|
||||
|
||||
## Migration (doctor + gateway auto-run)
|
||||
|
||||
Inputs
|
||||
- `agent.model` (string)
|
||||
- `agent.modelFallbacks` (string[])
|
||||
- `agent.imageModel` (string)
|
||||
- `agent.imageModelFallbacks` (string[])
|
||||
- `agent.allowedModels` (string[])
|
||||
- `agent.modelAliases` (record)
|
||||
|
||||
Outputs
|
||||
- `agent.models` map with keys for all referenced models
|
||||
- `agent.model.primary/fallbacks`
|
||||
- `agent.imageModel.primary/fallbacks`
|
||||
- `auth.profiles` seeded from current auth.json + env (as `provider:default`)
|
||||
- `auth.order` seeded with `["provider:default"]`
|
||||
|
||||
Auto-run
|
||||
- Gateway start detects legacy keys and runs doctor migration.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Auth order is per-provider (`auth.order`).
|
||||
- Doctor migration is required; gateway will auto-run on startup when legacy keys detected.
|
||||
- `/model Opus@profile` is explicit session override only.
|
||||
- Image routing override only when `agent.imageModel` is explicitly configured.
|
||||
@ -1,65 +0,0 @@
|
||||
---
|
||||
summary: "Refactor plan: unify agent lifecycle events and wait semantics"
|
||||
read_when:
|
||||
- Refactoring agent lifecycle events or wait behavior
|
||||
---
|
||||
# Refactor: Agent Loop
|
||||
|
||||
Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end".
|
||||
|
||||
## Problem
|
||||
- Two lifecycles today:
|
||||
- `job` (gateway wrapper) => used by `agent.wait` + chat final
|
||||
- pi-agent `agent_end` (inner loop) => only logged
|
||||
- This can finalize early (job done) while late assistant deltas still arrive.
|
||||
- `afterMs` and timeouts can cause false timeouts in `agent.wait`.
|
||||
|
||||
## Reference (mom)
|
||||
- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream.
|
||||
- `waitForIdle()` resolves on `agent_end`.
|
||||
- No separate job state exposed to clients.
|
||||
|
||||
## Proposed refactor (breaking allowed)
|
||||
1) Replace public `job` stream with `lifecycle` stream
|
||||
- `stream: "lifecycle"`
|
||||
- `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }`
|
||||
2) `agent.wait` waits on lifecycle end/error only
|
||||
- remove `afterMs`
|
||||
- return `{ runId, status, startedAt, endedAt, error? }`
|
||||
3) Chat final emitted on lifecycle end only
|
||||
- deltas still from `assistant` stream
|
||||
4) Centralize run registry
|
||||
- one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText
|
||||
- clear on lifecycle end
|
||||
|
||||
## Implementation outline
|
||||
- `src/agents/pi-embedded-subscribe.ts`
|
||||
- emit lifecycle start/end events (translate pi `agent_start`/`agent_end`)
|
||||
- `src/infra/agent-events.ts`
|
||||
- add `"lifecycle"` to stream type
|
||||
- `src/gateway/protocol/schema.ts`
|
||||
- update AgentEvent schema; update AgentWait params (remove afterMs, add status)
|
||||
- `src/gateway/server-methods/agent-job.ts`
|
||||
- rename to `agent-wait.ts` or similar; wait on lifecycle end/error
|
||||
- `src/gateway/server-chat.ts`
|
||||
- finalize on lifecycle end (not job)
|
||||
- `src/commands/agent.ts`
|
||||
- stop emitting `job` externally (keep internal log if needed)
|
||||
|
||||
## Migration notes (breaking)
|
||||
- Update all callers of `agent.wait` to new response shape.
|
||||
- Update tests that expect `timeout` based on job events.
|
||||
- If any UI relies on job state, map lifecycle instead.
|
||||
|
||||
## Risks
|
||||
- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast.
|
||||
- Late deltas after lifecycle end should be ignored; keep seq tracking + drop.
|
||||
|
||||
## Acceptance
|
||||
- One lifecycle visible to clients.
|
||||
- `agent.wait` resolves when agent loop ends, not wrapper completion.
|
||||
- Chat final never emits before last assistant delta.
|
||||
|
||||
## Rollout (if we wanted safety)
|
||||
- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`.
|
||||
- Remove legacy after one release.
|
||||
@ -1,58 +0,0 @@
|
||||
---
|
||||
summary: "Refactor: simplify browser control API + implementation"
|
||||
read_when:
|
||||
- Refactoring browser control routes, client, or CLI
|
||||
- Auditing agent-facing browser tool surface
|
||||
date: 2025-12-20
|
||||
---
|
||||
|
||||
# Refactor: Browser control simplification
|
||||
|
||||
Goal: make the browser-control surface **small, stable, and agent-oriented**, and remove “implementation-shaped” APIs (Playwright/CDP specifics, one-off endpoints, and debugging helpers).
|
||||
|
||||
## Why
|
||||
|
||||
- The previous API accreted many narrow endpoints (`/click`, `/type`, `/press`, …) plus debug utilities.
|
||||
- Some actions are inherently racy when modeled as “do X *when* the event is already visible” (file chooser, dialogs).
|
||||
- We want a single, coherent contract that keeps “how it’s implemented” private.
|
||||
|
||||
## Target contract (vNext)
|
||||
|
||||
**Basics**
|
||||
- `GET /` status
|
||||
- `POST /start`, `POST /stop`
|
||||
- `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
||||
|
||||
**Agent actions**
|
||||
- `POST /navigate` `{ url, targetId? }`
|
||||
- `POST /act` `{ kind, targetId?, ... }` where `kind` is one of:
|
||||
- `click`, `type`, `press`, `hover`, `drag`, `select`, `fill`, `wait`, `resize`, `close`, `evaluate`
|
||||
- `POST /screenshot` `{ targetId?, fullPage?, ref?, element?, type? }`
|
||||
- `GET /snapshot` `?format=ai|aria&targetId?&limit?`
|
||||
- `GET /console` `?level?&targetId?`
|
||||
- `POST /pdf` `{ targetId? }`
|
||||
|
||||
**Hooks (pre-setup / arming)**
|
||||
- `POST /hooks/file-chooser` `{ targetId?, paths, timeoutMs? }`
|
||||
- `POST /hooks/dialog` `{ targetId?, accept, promptText?, timeoutMs? }`
|
||||
|
||||
Semantics:
|
||||
- Hook endpoints **arm** the next matching event within `timeoutMs` (default 2 minutes, clamped to max 2 minutes).
|
||||
- Last arm wins per page (new arm replaces previous).
|
||||
|
||||
## Work checklist
|
||||
|
||||
- [x] Replace action endpoints with `POST /act`
|
||||
- [x] Remove legacy endpoints (`/click`, `/type`, `/wait`, …) and any CLI wrappers that no longer make sense
|
||||
- [x] Remove `/back` and any history-specific routes
|
||||
- [x] Convert `upload` + `dialog` to hook/arming endpoints
|
||||
- [x] Unify screenshots behind `POST /screenshot` (no GET variant)
|
||||
- [x] Trim inspect/debug endpoints (`/query`, `/dom`) unless explicitly needed
|
||||
- [x] Update docs/browser.md to describe contract without implementation details
|
||||
- [x] Update tests (server + client) to cover vNext contract
|
||||
|
||||
## Notes / decisions
|
||||
|
||||
- Keep Playwright as an internal implementation detail for now.
|
||||
- Prefer ref-based interactions (`aria-ref`) over coordinate-based ones.
|
||||
- Keep the code split “routes vs. engine” small and obvious; avoid scattering logic across too many files.
|
||||
@ -1,93 +0,0 @@
|
||||
---
|
||||
summary: "Refactor: host A2UI from the Gateway (HTTP), remove app-bundled shells"
|
||||
read_when:
|
||||
- Refactoring Canvas/A2UI ownership or assets
|
||||
- Moving UI rendering from native bundles into the Gateway
|
||||
- Updating node canvas navigation or A2UI command flows
|
||||
---
|
||||
|
||||
# Canvas / A2UI — HTTP-hosted from Gateway
|
||||
|
||||
Status: Implemented · Date: 2025-12-20
|
||||
|
||||
## Goal
|
||||
- Make the **Gateway (TypeScript)** the single owner of A2UI.
|
||||
- Remove **app-bundled A2UI shells** (macOS, iOS, Android).
|
||||
- A2UI renders only when the **Gateway is reachable** (acceptable failure mode).
|
||||
|
||||
## Decision
|
||||
All A2UI HTML/JS assets are **served by the Gateway canvas host** on
|
||||
`canvasHost.port` (default `18793`), bound to the **bridge interface**. Nodes
|
||||
(mac/iOS/Android) **navigate to the advertised `canvasHostUrl`** before applying
|
||||
A2UI messages. No local custom-scheme or bundled fallback remains.
|
||||
|
||||
## Why
|
||||
- One source of truth (TS) for A2UI rendering.
|
||||
- Faster iteration (no app release required for A2UI updates).
|
||||
- iOS/Android/macOS all behave identically.
|
||||
|
||||
## New behavior (summary)
|
||||
1) `canvas.a2ui.*` on any node:
|
||||
- Ensure Canvas is visible.
|
||||
- Navigate the node WebView to the Gateway A2UI URL.
|
||||
- Apply/reset A2UI messages once the page is ready.
|
||||
2) If Gateway is unreachable:
|
||||
- A2UI fails with an explicit error (no fallback).
|
||||
|
||||
## Gateway changes
|
||||
|
||||
### Serve A2UI assets
|
||||
Add A2UI HTML/JS to the Gateway Canvas host (standalone HTTP server on
|
||||
`canvasHost.port`), e.g.:
|
||||
|
||||
```
|
||||
/__clawdbot__/a2ui/ -> index.html
|
||||
/__clawdbot__/a2ui/a2ui.bundle.js -> bundled A2UI runtime
|
||||
```
|
||||
|
||||
Serve Canvas files at `/__clawdbot__/canvas/` and A2UI at `/__clawdbot__/a2ui/`.
|
||||
Use the shared Canvas host handler (`src/canvas-host/server.ts`) to serve these
|
||||
assets and inject the action bridge + live reload if desired.
|
||||
|
||||
### Canonical host URL
|
||||
The Gateway exposes a **canonical** `canvasHostUrl` in hello/bridge payloads
|
||||
so nodes don’t need to guess.
|
||||
|
||||
## Node changes (mac/iOS/Android)
|
||||
|
||||
### Navigation path
|
||||
Before applying A2UI:
|
||||
- Navigate to `${canvasHostUrl}/__clawdbot__/a2ui/`.
|
||||
|
||||
### Remove bundled shells
|
||||
Remove all fallback logic that serves A2UI from local bundles:
|
||||
- macOS: remove custom-scheme fallback for `/__clawdbot__/a2ui/`
|
||||
- iOS/Android: remove packaged A2UI assets and "default scaffold" assumptions
|
||||
|
||||
### Error behavior
|
||||
If `canvasHostUrl` is missing or unreachable:
|
||||
- `canvas.a2ui.push/reset` returns a clear error:
|
||||
- `A2UI_HOST_UNAVAILABLE` or `A2UI_HOST_NOT_CONFIGURED`
|
||||
|
||||
## Security / transport
|
||||
- For non-TLS Gateway URLs (http), iOS/Android will need ATS exceptions.
|
||||
- For TLS (https), prefer WSS + HTTPS with a valid cert.
|
||||
|
||||
## Implementation plan
|
||||
1) Gateway
|
||||
- Add A2UI assets under `src/canvas-host/`.
|
||||
- Serve them at `/__clawdbot__/a2ui/` (align with existing naming).
|
||||
- Serve Canvas files at `/__clawdbot__/canvas/` on `canvasHost.port`.
|
||||
- Expose `canvasHostUrl` in handshake + bridge hello payloads.
|
||||
2) Node runtimes
|
||||
- Update `canvas.a2ui.*` to navigate to `canvasHostUrl`.
|
||||
- Remove custom-scheme A2UI fallback and bundled assets.
|
||||
3) Tests
|
||||
- TS: verify `/__clawdbot__/a2ui/` responds with HTML + JS.
|
||||
- Node: verify A2UI fails when host is unreachable and succeeds when reachable.
|
||||
4) Docs
|
||||
- Update `docs/mac/canvas.md`, `docs/ios/spec.md`, `docs/android/connect.md`
|
||||
to remove local fallback assumptions and point to gateway-hosted A2UI.
|
||||
|
||||
## Notes
|
||||
- iOS/Android may still require ATS exceptions for `http://` canvas hosts.
|
||||
@ -1,64 +0,0 @@
|
||||
---
|
||||
summary: "Refactor: unify on the clawdbot CLI + gateway-first control; retire clawdbot-mac"
|
||||
read_when:
|
||||
- Removing or replacing the macOS CLI helper
|
||||
- Adding node capabilities or permissions metadata
|
||||
- Updating macOS app packaging/install flows
|
||||
---
|
||||
|
||||
# CLI unification (clawdbot-only)
|
||||
|
||||
Status: active refactor · Date: 2025-12-20
|
||||
|
||||
## Goals
|
||||
- **Single CLI**: use `clawdbot` for all automation (local + remote). Retire `clawdbot-mac`.
|
||||
- **Gateway-first**: all agent actions flow through the Gateway WebSocket + node.invoke.
|
||||
- **Permission awareness**: nodes advertise permission state so the agent can decide what to run.
|
||||
- **No duplicate paths**: remove macOS control socket + Swift CLI surface.
|
||||
|
||||
## Non-goals
|
||||
- Keep legacy `clawdbot-mac` compatibility.
|
||||
- Support agent control when no Gateway is running.
|
||||
|
||||
## Key decisions
|
||||
1) **No Gateway → no control**
|
||||
- If the macOS app is running but the Gateway is not, remote commands (canvas/run/notify) are unavailable.
|
||||
- This is acceptable to keep one network surface.
|
||||
|
||||
2) **Remove ensure-permissions CLI**
|
||||
- Permissions are **advertised by the node** (e.g., screen recording granted/denied).
|
||||
- Commands will still fail with explicit errors when permissions are missing.
|
||||
|
||||
3) **Mac app installs/symlinks `clawdbot`**
|
||||
- Bundle a standalone `clawdbot` binary in the app (bun-compiled).
|
||||
- Install/symlink that binary to `/usr/local/bin/clawdbot` and `/opt/homebrew/bin/clawdbot`.
|
||||
- No `clawdbot-mac` helper remains.
|
||||
|
||||
4) **Canvas parity across node types**
|
||||
- Use `node.invoke` commands consistently (`canvas.present|navigate|eval|snapshot|a2ui.*`).
|
||||
- The TS CLI provides convenient wrappers so agents never have to craft raw `node.invoke` calls.
|
||||
|
||||
## Command surface (new/normalized)
|
||||
- `clawdbot nodes invoke --command canvas.*` remains valid.
|
||||
- New CLI wrappers for convenience:
|
||||
- `clawdbot canvas present|navigate|eval|snapshot|a2ui push|a2ui reset`
|
||||
- New node commands (mac-only initially):
|
||||
- `system.run` (shell execution)
|
||||
- `system.notify` (local notifications)
|
||||
|
||||
## Permission advertising
|
||||
- Node hello/pairing includes a `permissions` map:
|
||||
- Example keys: `screenRecording`, `accessibility`, `microphone`, `notifications`, `speechRecognition`.
|
||||
- Values: boolean (`true` = granted, `false` = not granted).
|
||||
- Gateway `node.list` / `node.describe` surfaces the map.
|
||||
|
||||
## Gateway mode + config
|
||||
- Gateways should only auto-start when explicitly configured for **local** mode.
|
||||
- When config is missing or explicitly remote, `clawdbot gateway` should refuse to auto-start unless forced.
|
||||
|
||||
## Implementation checklist
|
||||
- Add bun-compiled `clawdbot` binary to macOS app bundle; update codesign + install flows.
|
||||
- Remove `ClawdbotCLI` target and control socket server.
|
||||
- Add node command(s) for `system.run` and `system.notify` on macOS.
|
||||
- Add permission map to node hello/pairing + gateway responses.
|
||||
- Update TS CLI + docs to use `clawdbot` only.
|
||||
@ -1,31 +0,0 @@
|
||||
---
|
||||
summary: "Refactor notes for the macOS gateway client typed API migration (Dec 2025)."
|
||||
read_when:
|
||||
- Refactoring macOS gateway client or typed gateway methods
|
||||
- Auditing agent routing or channel semantics
|
||||
---
|
||||
|
||||
# Gateway Client Refactor (Dec 2025)
|
||||
|
||||
Goal: remove stringly-typed gateway calls from the macOS app, centralize routing/channel semantics, and improve error handling.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Fold legacy “AgentRPC” into `GatewayConnection` (single layer; no separate client object).
|
||||
- [x] Typed gateway API: `GatewayConnection.Method` + `requestDecoded/requestVoid` + typed helpers (status/agent/chat/cron/etc).
|
||||
- [x] Centralize agent routing/channel semantics via `GatewayAgentChannel` + `GatewayAgentInvocation`.
|
||||
- [x] Improve gateway error model (structured `GatewayResponseError` + decoding errors include method).
|
||||
- [x] Migrate mac call sites to typed helpers (leave only intentionally dynamic forwarding paths).
|
||||
- [x] Convert remaining UI raw channel strings to `GatewayAgentChannel` (Cron editor).
|
||||
- [x] Cleanup naming: rename remaining tests/docs that still reference “RPC/AgentRPC”.
|
||||
|
||||
### Notes
|
||||
|
||||
- Intentionally string-based:
|
||||
- `BridgeServer` dynamic request forwarding (method is data-driven).
|
||||
- `ControlChannel` request wrapper (generic escape hatch).
|
||||
|
||||
## Notes / Non-goals
|
||||
|
||||
- No functional behavior changes intended (beyond better errors and removing “magic strings”).
|
||||
- Keep changes incremental: introduce typed APIs first, then migrate call sites, then remove old helpers.
|
||||
@ -1,99 +0,0 @@
|
||||
---
|
||||
summary: "Refactor notes for the macOS gateway client: single shared websocket + follow-ups"
|
||||
read_when:
|
||||
- Investigating duplicate/stale Gateway WS connections
|
||||
- Refactoring macOS gateway client architecture
|
||||
- Debugging noisy reconnect storms on gateway restart
|
||||
---
|
||||
# Gateway Refactor Notes (macOS client)
|
||||
|
||||
Last updated: 2025-12-12
|
||||
|
||||
This document captures the rationale and outcome of the macOS app’s Gateway client refactor: **one shared websocket connection per app process**, with an in-process event bus for server push frames.
|
||||
|
||||
Related docs:
|
||||
- `docs/refactor/new-arch.md` (overall gateway protocol/server plan)
|
||||
- `docs/gateway.md` (gateway operations/runbook)
|
||||
- `docs/presence.md` (presence semantics and dedupe)
|
||||
- `docs/mac/webchat.md` (WebChat surfaces and debugging)
|
||||
|
||||
---
|
||||
|
||||
## Background: what was wrong
|
||||
|
||||
Symptoms:
|
||||
- Restarting the gateway produced a *storm* of reconnects/log spam (`gateway/ws in connect`, `hello`, `hello-ok`) and elevated `clients=` counts.
|
||||
- Even with “one panel open”, the mac app could hold tens of websocket connections to `ws://127.0.0.1:18789`.
|
||||
|
||||
Root cause (historical bug):
|
||||
- The mac app was repeatedly “reconfiguring” a gateway client on a timer (via health polling), creating a new websocket owner each time.
|
||||
- Old websocket owners were not fully torn down and could keep watchdog/tick tasks alive, leading to **connection accumulation** over time.
|
||||
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **One socket owner:** `GatewayConnection.shared` is the only supported entry point for gateway RPC.
|
||||
- **No global notifications:** server push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream<GatewayPush>` (no `NotificationCenter` fan-out).
|
||||
- **No tunnel side effects:** `GatewayConnection` does not create/ensure SSH tunnels in remote mode; it consumes the already-established forwarded port.
|
||||
|
||||
---
|
||||
|
||||
## Current architecture (as of 2025-12-12)
|
||||
|
||||
Goal: enforce the invariant **“one gateway websocket per app process (per effective config)”**.
|
||||
|
||||
Key elements:
|
||||
- `GatewayConnection.shared` owns the one websocket and is the *only* supported entry point for app code that needs gateway RPC.
|
||||
- Consumers (e.g. Control UI, agent invocations, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets.
|
||||
- If the effective connection config changes (local ↔ remote tunnel port, token change), `GatewayConnection` replaces the underlying connection.
|
||||
- The transport (`GatewayChannelActor`) is an internal detail and forwards push frames back into `GatewayConnection`.
|
||||
- Server-push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream<GatewayPush>` (in-process event bus).
|
||||
|
||||
Notes:
|
||||
- Remote mode requires an SSH control tunnel. `GatewayConnection` **does not** start tunnels; it consumes the already-established forwarded port (owned by `ConnectionModeCoordinator` / `RemoteTunnelManager`).
|
||||
|
||||
---
|
||||
|
||||
## Design constraints / principles
|
||||
|
||||
- **Single ownership:** Exactly one component owns the actual socket and reconnect policy.
|
||||
- **Explicit config changes:** Recreate/reconnect only when config changes, not as a side effect of periodic work.
|
||||
- **No implicit fan-out sockets:** Adding new UI features must not accidentally add new persistent gateway connections.
|
||||
- **Testable seams:** Connection config and websocket session creation should be overridable in tests.
|
||||
|
||||
---
|
||||
|
||||
## Status / remaining work
|
||||
|
||||
- ✅ One shared websocket per app process (per config)
|
||||
- ✅ Event streaming moved into `GatewayConnection` (`AsyncStream<GatewayPush>`) and replays latest snapshot to new subscribers
|
||||
- ✅ `NotificationCenter` removed for in-process gateway events (ControlChannel / Instances / WebChatSwiftUI)
|
||||
- ✅ Remote tunnel lifecycle is not started implicitly by random RPC calls
|
||||
- ✅ Payload decoding helpers extracted so UI adapters stay thin
|
||||
- ✅ Dedicated resolved-endpoint publisher for remote mode (`GatewayEndpointStore`)
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy (what we want to cover)
|
||||
|
||||
Minimum invariants:
|
||||
- Repeated requests under the same config do **not** create additional websocket tasks.
|
||||
- Concurrent requests still create **exactly one** websocket and reuse it.
|
||||
- Shutdown prevents any reconnect loop after failures.
|
||||
- Config changes (token / endpoint) cancel the old socket and reconnect once.
|
||||
|
||||
Nice-to-have integration coverage:
|
||||
- Multiple “consumers” (Control UI + agent invocations + SwiftUI WebChat) all call through the shared connection and still produce only one websocket.
|
||||
|
||||
Additional coverage added (macOS):
|
||||
- Subscribing after connect replays the latest snapshot.
|
||||
- Sequence gaps emit an explicit `GatewayPush.seqGap(...)` before the corresponding event.
|
||||
|
||||
---
|
||||
|
||||
## Debug notes (operational)
|
||||
|
||||
When diagnosing “too many connections”:
|
||||
- Prefer counting actual TCP connections on port 18789 and grouping by PID to see which process is holding sockets.
|
||||
- Gateway `--verbose` prints *every* connect/hello and event broadcast; use it only when needed and filter output if you’re just sanity-checking.
|
||||
@ -1,171 +0,0 @@
|
||||
---
|
||||
summary: "Implementation plan for the new gateway architecture and protocol"
|
||||
read_when:
|
||||
- Executing the gateway refactor
|
||||
---
|
||||
# New Gateway Architecture – Implementation Plan (detailed)
|
||||
|
||||
Last updated: 2025-12-09
|
||||
|
||||
Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway, typed protocol, and first-frame snapshot. No backward compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Foundations
|
||||
- **Naming**: CLI subcommand `clawdbot gateway`; internal namespace `Gateway`.
|
||||
- **Protocol folder**: create `protocol/` for schemas and build artifacts. ✅ `src/gateway/protocol`.
|
||||
- **Schema tooling**:
|
||||
- Prefer **TypeBox** (or ArkType) as source-of-truth types. ✅ TypeBox in `schema.ts`.
|
||||
- `pnpm protocol:gen`: emits JSON Schema (`dist/protocol.schema.json`). ✅
|
||||
- `pnpm protocol:gen:swift`: generates Swift `Codable` models (`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`). ✅
|
||||
- AJV compile step for server validators. ✅
|
||||
- **CI**: add a job that fails if schema or generated Swift is stale. ✅ `pnpm protocol:check` (runs gen + git diff).
|
||||
|
||||
## Phase 1 — Protocol specification
|
||||
- Frames (WS text JSON, all with explicit `type`):
|
||||
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
||||
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
|
||||
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
||||
- `req {type:"req", id, method, params?}`
|
||||
- `res {type:"res", id, ok, payload?, error?}` where `error` = `{code,message,details?,retryable?,retryAfterMs?}`
|
||||
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
||||
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
|
||||
- Payload types:
|
||||
- `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
|
||||
- `HealthSnapshot` (match existing `clawdbot health --json` fields)
|
||||
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
|
||||
- `TickEvent {ts}`
|
||||
- `ShutdownEvent {reason, restartExpectedMs?}`
|
||||
- Error codes: `NOT_LINKED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`.
|
||||
- Error shape: `{code, message, details?, retryable?, retryAfterMs?}`
|
||||
- Rules:
|
||||
- First frame must be `req` with `method:"connect"`; otherwise close. Add handshake timeout (e.g., 3s) for silent clients.
|
||||
- Negotiate protocol: server picks within `[minProtocol,maxProtocol]`; if none, reply `res ok:false` and close.
|
||||
- Protocol version bump on breaking changes; `hello-ok` must include `minClient` when needed.
|
||||
- `stateVersion` increments for presence/health to drop stale deltas.
|
||||
- Stable IDs: client sends `instanceId`; server issues per-connection `connId` in `hello-ok`; presence entries may include `instanceId` to dedupe reconnects.
|
||||
- Token-based auth: bearer token in `auth.token`; required except for loopback development.
|
||||
- Presence is primarily connection-derived; client may add hints (e.g., lastInputSeconds); entries expire via TTL to keep the map bounded (e.g., 5m TTL, max 200 entries).
|
||||
- Idempotency keys: required for `send` and `agent` to safely retry after disconnects.
|
||||
- Size limits: bound first-frame size by `maxPayload`; reject early if exceeded.
|
||||
- Close on any non-JSON or wrong `type` before connect.
|
||||
- Per-op idempotency keys: client SHOULD supply an explicit key per `send`/`agent`; if omitted, server may derive a scoped key from `instanceId+connId`, but explicit keys are safer across reconnects.
|
||||
- Locale/userAgent are informational; server may log them for analytics but must not rely on them for access control.
|
||||
|
||||
## Phase 2 — Gateway WebSocket server
|
||||
- New module `src/gateway/server.ts`:
|
||||
- Bind 127.0.0.1:18789 (configurable).
|
||||
- On connect: validate `connect` params, send snapshot payload, start event pump.
|
||||
- Per-connection queues with backpressure (bounded; drop oldest non-critical).
|
||||
- WS-level caps: set `maxPayload` to cap frame size before JSON parse.
|
||||
- Emit `tick` every N seconds when idle (or WS ping/pong if adequate).
|
||||
- Emit `shutdown` before exit; then close sockets.
|
||||
- Methods implemented:
|
||||
- `health`, `status`, `system-presence`, `system-event`, `send`, `agent`.
|
||||
- Optional: `set-heartbeats` removed/renamed if heartbeat concept is retired.
|
||||
- Events implemented:
|
||||
- `agent`, `presence` (deltas, with `stateVersion`), `tick`, `shutdown`.
|
||||
- All events include `seq` for loss/out-of-order detection.
|
||||
- Logging: structured logs on connect/close/error; include client fingerprint.
|
||||
- Slow consumer policy:
|
||||
- Per-connection outbound queue limit (bytes/messages). If exceeded, drop non-critical events (presence/tick) or close with a policy violation / retryable code; clients reconnect with backoff.
|
||||
- Handshake edge cases:
|
||||
- Close on handshake timeout.
|
||||
- Close on over-limit first frame (maxPayload).
|
||||
- Close immediately on non-JSON or wrong `type` before connect.
|
||||
- Default guardrails: `maxPayload` ~512 KB, handshake timeout ~3 s, outbound buffered amount cap ~1.5 MB (tune as you implement).
|
||||
- Dedupe cache: bound TTL (~5m) and max size (~1000 entries); evict oldest first (LRU) to prevent memory growth.
|
||||
|
||||
## Phase 3 — Gateway CLI entrypoint
|
||||
- Add `clawdbot gateway` command in CLI program:
|
||||
- Reads config (port, WS options).
|
||||
- Foreground process; exit non-zero on fatal errors.
|
||||
- Flags: `--port`, `--no-tick` (optional), `--log-json` (optional).
|
||||
- System supervision docs for launchd/systemd (see `gateway.md`).
|
||||
|
||||
## Phase 4 — Presence/health snapshot & stateVersion
|
||||
- `hello-ok.snapshot` includes:
|
||||
- `presence[]` (current list)
|
||||
- `health` (full snapshot)
|
||||
- `stateVersion {presence:int, health:int}`
|
||||
- `uptimeMs`
|
||||
- `policy {maxPayload, maxBufferedBytes, tickIntervalMs}`
|
||||
- Emit `presence` deltas with updated `stateVersion.presence`.
|
||||
- Emit `tick` to indicate liveness when no other events occur.
|
||||
- Keep `health` method for manual refresh; not required after connect.
|
||||
- Presence expiry: prune entries older than TTL; enforce a max map size; include `stateVersion` in presence events.
|
||||
|
||||
## Phase 5 — Clients migration
|
||||
- **macOS app**:
|
||||
- Replace stdio/SSH RPC with WS client (tunneled via SSH/Tailscale for remote). ✅ GatewayConnection/ControlChannel now use Gateway WS.
|
||||
- Implement handshake, snapshot hydration, subscriptions to `presence`, `tick`, `agent`, `shutdown`. ✅ snapshot + presence events broadcast to InstancesStore; agent events still to wire to UI if desired.
|
||||
- Remove immediate `health/system-presence` fetch on connect. ✅ presence hydrated from snapshot; periodic refresh kept as fallback.
|
||||
- Handle connect failures (`res ok:false`) and retry with backoff if version/token mismatched. ✅ macOS GatewayChannel reconnects with exponential backoff.
|
||||
- **CLI**:
|
||||
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS.
|
||||
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
|
||||
- **WebChat backend**:
|
||||
- Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via the WebChat gateway client in `webchat/server.ts`.
|
||||
- Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable)
|
||||
|
||||
## Phase 6 — Send/agent path hardening
|
||||
- Ensure only the Gateway can open Baileys; no IPC fallback.
|
||||
- `send` executes in-process; respond with explicit result/error, not via heartbeat.
|
||||
- `agent` spawns Pi; respond quickly with `{runId,status:"accepted"}` (ack); stream `event:agent {runId, seq, stream, data, ts}`; final `res:agent {runId, status:"ok"|"error", summary}` completes request (idempotent via key).
|
||||
- Idempotency: side-effecting methods (`send`, `agent`) accept an idempotency key; keep a short-lived dedupe cache to avoid double-send on client retries. Client retry flow: on timeout/close, retry with same key; Gateway returns cached result when available; cache TTL ~5m and bounded.
|
||||
- Agent stream ordering: enforce monotonic `seq` per runId; if gap detected by server, terminate stream with error; if detected by client, issue a retry with same idempotency key.
|
||||
- Send response shape: `{messageId?, toJid?, error?}` and always include `runId` when available for traceability.
|
||||
|
||||
## Phase 7 — Keepalive and shutdown semantics
|
||||
- Keepalive: `tick` events (or WS ping/pong) at fixed interval; clients treat missing ticks as disconnect and reconnect.
|
||||
- Shutdown: send `event:shutdown {reason, restartExpectedMs?}` then close sockets; clients auto-reconnect.
|
||||
- Restart semantics: close sockets with a standard retryable close code; on reconnect, `hello-ok` snapshot must be sufficient to rebuild UI without event replay.
|
||||
- Use a standard close code (e.g., 1012 service restart or 1001 going away) for planned restart; 1008 policy violation for slow consumers.
|
||||
- Include `policy` in `hello-ok` so clients know the tick interval and buffer limits to tune their expectations.
|
||||
|
||||
## Phase 8 — Cleanup and deprecation
|
||||
- Retire `clawdbot rpc` as default path; keep only if explicitly requested (documented as legacy).
|
||||
- Remove reliance on `src/infra/control-channel.ts` for new clients; mark as legacy or delete after migration. ✅ file removed; mac app now uses Gateway WS.
|
||||
- Update README, docs (`architecture.md`, `gateway.md`, `webchat.md`) to final shapes; remove `control-api.md` references if obsolete.
|
||||
- Presence hygiene:
|
||||
- Presence derived primarily from connection (server-fills host/ip/version/connId/instanceId); allow client hints (e.g., lastInputSeconds).
|
||||
- Add TTL/expiry; prune to keep map bounded (e.g., 5m TTL, max 200 entries).
|
||||
|
||||
## Edge cases and ordering
|
||||
- Event ordering: all events carry `seq`; clients detect gaps and should re-fetch snapshot (or targeted refresh) on gap.
|
||||
- Partial handshakes: if client connects and never sends `req:connect`, server closes after handshake timeout.
|
||||
- Garbage/oversize first frame: bounded by `maxPayload`; server closes immediately on parse failure.
|
||||
- Duplicate delivery on reconnect: clients must send idempotency keys; Gateway dedupe cache prevents double-send/agent execution.
|
||||
- Snapshot sufficiency: `hello-ok.snapshot` must contain enough to render UI after reconnect without event replay.
|
||||
- Client reconnect guidance: exponential backoff with jitter; reuse same `instanceId` across reconnects to avoid duplicate presence; resend idempotency keys for in-flight sends/agents; on seq gap, issue `health`/`system-presence` refresh.
|
||||
- Presence TTL/defaults: set a concrete TTL (e.g., 5 minutes) and prune periodically; cap the presence map size with LRU if needed.
|
||||
- Replay policy: if seq gap detected, server does not replay; clients must pull fresh `health` + `system-presence` and continue.
|
||||
|
||||
## Phase 9 — Testing & validation
|
||||
- Unit: frame validation, handshake failure, auth/token, stateVersion on presence events, agent stream fanout, send dedupe. ✅
|
||||
- Integration: connect → snapshot → req/res → streaming agent → shutdown. ✅ Covered in gateway WS tests (connect/health/status/presence, agent ack+final, shutdown broadcast).
|
||||
- Load: multiple concurrent WS clients; backpressure behavior under burst. ✅ Basic fanout test with 3 clients receiving presence broadcast; heavier soak still recommended.
|
||||
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
|
||||
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
|
||||
- Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests.
|
||||
- Seq-gap handling: ✅ clients now detect seq gaps (WebChat gateway client + mac `GatewayConnection/GatewayChannel`) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional.
|
||||
|
||||
## Phase 10 — Rollout
|
||||
- Version bump; release notes: breaking change to control plane (WS only).
|
||||
- Ship launchd/systemd templates for `clawdbot gateway`.
|
||||
- Recommend Tailscale/SSH tunnel for remote access; no additional auth layer assumed in this model.
|
||||
|
||||
---
|
||||
|
||||
- Quick checklist
|
||||
- [x] Protocol types & schemas (TS + JSON Schema + Swift via quicktype)
|
||||
- [x] AJV validators wired
|
||||
- [x] WS server with connect → snapshot → events
|
||||
- [x] Tick + shutdown events
|
||||
- [x] stateVersion + presence deltas
|
||||
- [x] Gateway CLI command
|
||||
- [x] macOS app WS client (Gateway WS for control; presence events live; agent stream UI pending)
|
||||
- [x] WebChat WS client
|
||||
- [x] Remove legacy stdin/TCP paths from default flows (file removed; mac app/CLI on Gateway)
|
||||
- [x] Tests (unit/integration/load) — unit + integration + basic fanout/reconnect; heavier load/soak optional
|
||||
- [x] Docs updated and legacy docs flagged
|
||||
@ -1,26 +0,0 @@
|
||||
---
|
||||
summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI"
|
||||
read_when:
|
||||
- Building or refactoring the Gateway TUI
|
||||
- Syncing TUI slash commands with Clawdbot behavior
|
||||
---
|
||||
# Gateway TUI refactor plan
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## Goals
|
||||
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
|
||||
- Keep Clawdbot semantics: Gateway WS only, session store owns state, no branching/export.
|
||||
- Work locally or remotely via Gateway URL/token.
|
||||
|
||||
## Non-goals
|
||||
- Branching, export, OAuth flows, or hook UIs.
|
||||
- File-system operations on the Gateway host from the TUI.
|
||||
|
||||
## Checklist
|
||||
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
||||
- [x] Gateway TUI client: add session/model helpers + stricter typing.
|
||||
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
||||
- [x] TUI controller: keybindings + Clawdbot slash commands + history/stream wiring.
|
||||
- [x] Docs + changelog updated for the new TUI behavior.
|
||||
- [x] Gate: lint, build, tests, docs list.
|
||||
@ -1,37 +0,0 @@
|
||||
---
|
||||
summary: "Troubleshooting guide for the web gateway/Baileys stack"
|
||||
read_when:
|
||||
- Diagnosing web gateway socket or login issues
|
||||
---
|
||||
# Web Gateway Troubleshooting (Nov 26, 2025)
|
||||
|
||||
## Symptoms & quick fixes
|
||||
- **Stream Errored / Conflict / status 409–515:** WhatsApp closed the socket because another session is active or creds went stale. Run `clawdbot logout`, then `clawdbot login`, then restart the Gateway.
|
||||
- **Logged out:** Console prints “session logged out”; re-link with `clawdbot login`.
|
||||
- **Repeated retries then exit:** Tune reconnect behavior via config `web.reconnect` and restart the Gateway.
|
||||
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
||||
- **Status 515 right after pairing:** The QR login flow now auto-restarts once; you should not need a manual gateway restart after scanning.
|
||||
- **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to request a supervised restart (launchd/mac app setups); wait a few seconds for it to come back.
|
||||
|
||||
## Helpful commands
|
||||
- Start the Gateway: `clawdbot gateway --verbose`
|
||||
- Logout (clear creds): `clawdbot logout`
|
||||
- Relink (show QR): `clawdbot login --verbose`
|
||||
- Tail logs (default): `tail -f /tmp/clawdbot/clawdbot-*.log`
|
||||
|
||||
## Reading the logs
|
||||
- `web-reconnect`: close reasons, retry/backoff, max-attempt exit.
|
||||
- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default).
|
||||
- `web-auto-reply`: inbound/outbound message records with correlation IDs.
|
||||
|
||||
## When to tweak knobs
|
||||
- High churn networks: increase `web.reconnect.maxAttempts`.
|
||||
- Slow links: raise `web.reconnect.maxMs` to give more headroom before bailing.
|
||||
- Chatty monitors: increase `web.heartbeatSeconds` if log volume is high.
|
||||
|
||||
## If it keeps failing
|
||||
1) `clawdbot logout` → `clawdbot login` (fresh QR link).
|
||||
2) Ensure no other device/browser is using the same WA Web session.
|
||||
3) Check WhatsApp mobile app is online and not in low-power mode.
|
||||
4) If status is 515, let the client restart once after pairing (already handled automatically).
|
||||
5) Capture the last `web-reconnect` entry and the status code before escalating.
|
||||
@ -1,44 +0,0 @@
|
||||
---
|
||||
summary: "WebChat session migration notes (Gateway WS-only)"
|
||||
read_when:
|
||||
- Changing WebChat Gateway methods/events
|
||||
---
|
||||
# WebAgent session migration (WS-only)
|
||||
|
||||
Context: web chat currently lives in a WKWebView that loads the pi-web bundle. Sends go over HTTP `/rpc` to the webchat server, and updates come from `/socket` snapshots based on session JSONL file changes. The Gateway itself already speaks WebSocket to the webchat server, and Pi writes the session JSONL files. This doc tracks the plan to move WebChat to a single Gateway WebSocket and drop the HTTP shim/file-watching.
|
||||
|
||||
## Target state
|
||||
- Gateway WS adds methods:
|
||||
- `chat.history { sessionKey }` → `{ sessionKey, messages[], thinkingLevel }` (reads the existing JSONL + session store).
|
||||
- `chat.send { sessionKey, message, attachments?, thinking?, deliver?, timeoutMs<=30000, idempotencyKey }` → `res { runId, status:"accepted" }` or `res ok:false` on validation/timeout.
|
||||
- Gateway WS emits `chat` events `{ runId, sessionKey, seq, state:"delta"|"final"|"error", message?, errorMessage?, usage?, stopReason? }`. Streaming is optional; minimum is a single `state:"final"` per send.
|
||||
- Client consumes only WS: bootstrap via `chat.history`, send via `chat.send`, live updates via `chat` events. No file watchers.
|
||||
- Health gate: client subscribes to `health` and blocks send when health is not OK; 30s client-side timeout for sends.
|
||||
- Tunneling: only the Gateway WS port needs to be forwarded; HTTP server remains for static assets but no RPC endpoints.
|
||||
|
||||
## Server work (Node)
|
||||
- Implement `chat.history` and `chat.send` handlers in `src/gateway/server.ts`; update protocol schemas/tests.
|
||||
- Emit `chat` events by plumbing `agentCommand`/`emitAgentEvent` outputs; include assistant text/tool results.
|
||||
- Remove `/rpc` and `/socket` routes + file-watch broadcast from `src/webchat/server.ts`; leave static host only.
|
||||
|
||||
## Client work (pi-web bundle)
|
||||
- Replace `NativeTransport` with a Gateway WS client:
|
||||
- `connect` → `chat.history` for initial state.
|
||||
- Listen to `chat/presence/tick/health`; update UI from events only.
|
||||
- Send via `chat.send`; mark pending until `chat state:final|error`.
|
||||
- Enforce health gate + 30s timeout.
|
||||
- Remove reliance on session file snapshots and `/rpc`.
|
||||
|
||||
## Persistence
|
||||
- Keep passing `--session <.../.clawdbot/sessions/{{SessionId}}.jsonl>` to Pi so it continues writing JSONL. The WS history reader uses the same file; no new store introduced.
|
||||
|
||||
## Docs to update when shipping
|
||||
- `docs/webchat.md` (WS-only flow, methods/events, health gate, tunnel WS port).
|
||||
- `docs/mac/webchat.md` (WKWebView now talks Gateway WS; `/rpc`/file-watch removed).
|
||||
- `docs/architecture.md` / `typebox.md` if protocol methods are listed.
|
||||
- Optional: add a concise Gateway chat protocol appendix if needed.
|
||||
|
||||
## Open decisions
|
||||
- Streaming granularity: start with `state:"final"` only, or include token/tool deltas immediately?
|
||||
- Attachments over WS: text-only initially is OK; confirm before wiring binary/upload path.
|
||||
- Error shape: use `res ok:false` for validation/timeout, `chat state:"error"` for model/runtime failures.
|
||||
@ -1,3 +1,8 @@
|
||||
---
|
||||
summary: "SSH tunnel setup for Clawdbot.app connecting to a remote gateway"
|
||||
read_when: "Connecting the macOS app to a remote gateway over SSH"
|
||||
---
|
||||
|
||||
# Running Clawdbot.app with a Remote Gateway
|
||||
|
||||
Clawdbot.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up.
|
||||
|
||||
@ -109,10 +109,23 @@ pnpm clawdbot health
|
||||
- Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo.
|
||||
- Updating source: `git pull` + `pnpm install` (when lockfile changed) + keep using `pnpm gateway:watch`.
|
||||
|
||||
## Linux (systemd user service)
|
||||
|
||||
Linux installs use a systemd **user** service. By default, systemd stops user
|
||||
services on logout/idle, which kills the Gateway. Onboarding attempts to enable
|
||||
lingering for you (may prompt for sudo). If it’s still off, run:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
For always-on or multi-user servers, consider a **system** service instead of a
|
||||
user service (no lingering needed). See `docs/gateway.md` for the systemd notes.
|
||||
|
||||
## Related docs
|
||||
|
||||
- `docs/gateway.md` (Gateway runbook; flags, supervision, ports)
|
||||
- `docs/configuration.md` (config schema + examples)
|
||||
- `docs/discord.md` and `docs/telegram.md` (reply tags + replyToMode settings)
|
||||
- `docs/clawd.md` (personal assistant setup)
|
||||
- `docs/clawdbot-mac.md` (macOS app behavior; gateway lifecycle + “Attach only”)
|
||||
- `docs/macos.md` (macOS app behavior; gateway lifecycle + “Attach only”)
|
||||
|
||||
36
docs/showcase.md
Normal file
36
docs/showcase.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "Real-world showcases of what Clawdbot can do"
|
||||
read_when:
|
||||
- You want inspiration or proof of capability
|
||||
---
|
||||
# Showcase
|
||||
|
||||
Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
|
||||
|
||||
## Automation & real-world outcomes
|
||||
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
||||
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
||||
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn
|
||||
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)
|
||||
|
||||
## Knowledge & memory systems
|
||||
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
|
||||
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
|
||||
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
|
||||
|
||||
## Voice, docs, and assistants on the phone
|
||||
- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge
|
||||
- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.)
|
||||
- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link)
|
||||
|
||||
## Infrastructure & deployment
|
||||
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
|
||||
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
|
||||
- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
|
||||
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
|
||||
|
||||
## Home + hardware
|
||||
- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock
|
||||
|
||||
## Community builds (non‑Clawdis but made with/around it)
|
||||
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
|
||||
@ -1,3 +1,8 @@
|
||||
---
|
||||
summary: "Slack socket mode setup and Clawdbot config"
|
||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||
---
|
||||
|
||||
# Slack (socket mode)
|
||||
|
||||
## Setup
|
||||
|
||||
40
docs/timezone.md
Normal file
40
docs/timezone.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Timezone handling for agents, envelopes, and prompts"
|
||||
read_when:
|
||||
- You need to understand how timestamps are normalized for the model
|
||||
- Configuring the user timezone for system prompts
|
||||
---
|
||||
|
||||
# Timezones
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
```
|
||||
[Surface ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
|
||||
## Tool payloads (raw provider data)
|
||||
|
||||
Tool calls (`discord.readMessages`, `slack.readMessages`, etc.) return **raw provider timestamps**.
|
||||
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them.
|
||||
|
||||
## User timezone for the system prompt
|
||||
|
||||
Set `agent.userTimezone` to tell the model the user's local time zone. If it is
|
||||
unset, Clawdbot resolves the **host timezone at runtime** (no config write).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
}
|
||||
```
|
||||
|
||||
The system prompt includes:
|
||||
- `User timezone: America/Chicago`
|
||||
- `Current user time: 2026-01-05 15:26`
|
||||
@ -73,7 +73,7 @@ Common parameters:
|
||||
- `controlUrl` (defaults from config)
|
||||
- `profile` (optional; defaults to `browser.defaultProfile`)
|
||||
Notes:
|
||||
- Requires `browser.enabled=true` in `~/.clawdbot/clawdbot.json`.
|
||||
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
||||
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
|
||||
- All actions accept optional `profile` parameter for multi-instance support.
|
||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
|
||||
|
||||
@ -100,7 +100,7 @@ If you’re logged out / unlinked:
|
||||
|
||||
```bash
|
||||
clawdbot logout
|
||||
rm -rf ~/.clawdbot/credentials # if logout can't cleanly remove everything
|
||||
trash ~/.clawdbot/credentials # if logout can't cleanly remove everything
|
||||
clawdbot login --verbose # re-scan QR
|
||||
```
|
||||
|
||||
@ -203,7 +203,7 @@ tail -20 /tmp/clawdbot/clawdbot-*.log
|
||||
Nuclear option:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.clawdbot
|
||||
trash ~/.clawdbot
|
||||
clawdbot login # re-pair WhatsApp
|
||||
clawdbot gateway # start the Gateway again
|
||||
```
|
||||
|
||||
@ -136,7 +136,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
||||
## Logs + troubleshooting
|
||||
- Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`.
|
||||
- Log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (configurable).
|
||||
- Troubleshooting guide: `docs/refactor/web-gateway-troubleshooting.md`.
|
||||
- Troubleshooting guide: `docs/troubleshooting.md`.
|
||||
|
||||
## Tests
|
||||
- `src/web/auto-reply.test.ts` (mention gating, history injection, reply flow)
|
||||
|
||||
11
docs/windows.md
Normal file
11
docs/windows.md
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
summary: "Windows app status + contribution call"
|
||||
read_when:
|
||||
- Looking for Windows companion app status
|
||||
- Planning platform coverage or contributions
|
||||
---
|
||||
# Windows App
|
||||
|
||||
Clawdbot core is fully supported on Windows. The core is written in TypeScript, so it runs anywhere Node runs.
|
||||
|
||||
We do not have a Windows companion app yet. It is planned, and we would love contributions to make it happen.
|
||||
@ -52,7 +52,7 @@ It does **not** install or change anything on the remote host.
|
||||
- **API key**: stores the key for you.
|
||||
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||
- **Skip**: no auth configured yet.
|
||||
- OAuth + API keys are stored in `~/.clawdbot/agent/auth.json`.
|
||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; API keys live in `~/.clawdbot/agent/auth.json`.
|
||||
|
||||
3) **Workspace**
|
||||
- Default `~/clawd` (configurable).
|
||||
@ -74,8 +74,8 @@ It does **not** install or change anything on the remote host.
|
||||
- macOS: LaunchAgent
|
||||
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
|
||||
- Linux: systemd user unit
|
||||
- Wizard enables lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
|
||||
- Requires sudo (writes `/var/lib/systemd/linger`).
|
||||
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
|
||||
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
|
||||
- Windows: Scheduled Task
|
||||
- Runs on user logon; headless/system services are not configured by default.
|
||||
|
||||
|
||||
@ -111,8 +111,8 @@
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tslog": "^4.10.2",
|
||||
"undici": "^7.16.0",
|
||||
"ws": "^8.18.3",
|
||||
"undici": "^7.18.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -133,7 +133,7 @@
|
||||
"lucide": "^0.562.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"ollama": "^0.6.3",
|
||||
"oxlint": "^1.36.0",
|
||||
"oxlint": "^1.37.0",
|
||||
"oxlint-tsgolint": "^0.10.1",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"rolldown": "1.0.0-beta.58",
|
||||
|
||||
154
pnpm-lock.yaml
generated
154
pnpm-lock.yaml
generated
@ -27,13 +27,13 @@ importers:
|
||||
version: 1.3.4
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
|
||||
version: 0.36.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
|
||||
version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
|
||||
version: 0.36.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.0
|
||||
@ -110,11 +110,11 @@ importers:
|
||||
specifier: ^4.10.2
|
||||
version: 4.10.2
|
||||
undici:
|
||||
specifier: ^7.16.0
|
||||
version: 7.16.0
|
||||
specifier: ^7.18.0
|
||||
version: 7.18.0
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.18.3
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
@ -171,8 +171,8 @@ importers:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
oxlint:
|
||||
specifier: ^1.36.0
|
||||
version: 1.36.0(oxlint-tsgolint@0.10.1)
|
||||
specifier: ^1.37.0
|
||||
version: 1.37.0(oxlint-tsgolint@0.10.1)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.10.1
|
||||
version: 0.10.1
|
||||
@ -870,43 +870,43 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/darwin-arm64@1.36.0':
|
||||
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
|
||||
'@oxlint/darwin-arm64@1.37.0':
|
||||
resolution: {integrity: sha512-qDa8qf4Th3sbk6P6wRbsv5paGeZ8EEOy8PtnT2IkAYSzjDHavw8nMK/lQvf6uS7LArjcmOfM1Y3KnZUFoNZZqg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/darwin-x64@1.36.0':
|
||||
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
|
||||
'@oxlint/darwin-x64@1.37.0':
|
||||
resolution: {integrity: sha512-FM0h0KyOQ4HCdhIX1ne6d80BxRra75h1ORce0jYNwQ49HT4RU8+9ywSMC7rQ79xWsmaahvkQPB7tMPyfjsQwAg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/linux-arm64-gnu@1.36.0':
|
||||
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
|
||||
'@oxlint/linux-arm64-gnu@1.37.0':
|
||||
resolution: {integrity: sha512-2axK0lftGwM6Q7wOuY2sassUqa4MKrG3iemVVyEpXzJ6g5QosxhCoFPp9v81/gmLT5kAdd2gskoDcfpDJliDNw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/linux-arm64-musl@1.36.0':
|
||||
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
|
||||
'@oxlint/linux-arm64-musl@1.37.0':
|
||||
resolution: {integrity: sha512-f3YROyGMIdUeXx0yD7RsAUBzBvD222D4l2GQRYF3AMxyp9mya17Rq/3wNLR4JDnAnboOul3DAEKNm+09lo3uZw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/linux-x64-gnu@1.36.0':
|
||||
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
|
||||
'@oxlint/linux-x64-gnu@1.37.0':
|
||||
resolution: {integrity: sha512-FANOdOVQ2c4acYLM0dvtSoKELHSSnDBxDdm8OlXNzSRanQILrNpLgUqCXHFsfiHipFfNzz3Z417PxV6X4aBYog==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/linux-x64-musl@1.36.0':
|
||||
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
|
||||
'@oxlint/linux-x64-musl@1.37.0':
|
||||
resolution: {integrity: sha512-eYnSKT9knXdOQ9h+6nSjEHSx0+pW8PkGwtMNGXtCYR+/ZPKYIbtZVS0nZsFy+qizP+TRVSJrgc/JY3Xr0wjcQg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/win32-arm64@1.36.0':
|
||||
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
|
||||
'@oxlint/win32-arm64@1.37.0':
|
||||
resolution: {integrity: sha512-2oHxNc4jcocfNWGWVVWQdEG+reZ5ncBZsmDoICJQ1rbCDx4Yimx8VUf1Ub9cCoJRcPiSLBxMqaeMaDClKixJIQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/win32-x64@1.36.0':
|
||||
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
|
||||
'@oxlint/win32-x64@1.37.0':
|
||||
resolution: {integrity: sha512-w+pBuTjGmGCGPhDjFhj/97K2tlGyq5LKAU6S7FHxROPuJRWJD6uio1L75Lsb8fKhwtw2rm54LLOX30Yi+nILxw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@ -1951,8 +1951,8 @@ packages:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hookified@1.14.0:
|
||||
resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==}
|
||||
hookified@1.15.0:
|
||||
resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
@ -2418,8 +2418,8 @@ packages:
|
||||
resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.36.0:
|
||||
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
|
||||
oxlint@1.37.0:
|
||||
resolution: {integrity: sha512-MAw0JH8M5/vt9E2WxSsmJu53bVLmG6qNlVw1OXFenJYItTPbMBtW7j3n53+tgNhNuxFPundM1DR7V8E39qOOrg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -2436,8 +2436,8 @@ packages:
|
||||
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-queue@9.0.1:
|
||||
resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==}
|
||||
p-queue@9.1.0:
|
||||
resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-retry@4.6.2:
|
||||
@ -2933,8 +2933,8 @@ packages:
|
||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
undici@7.18.0:
|
||||
resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
@ -3073,8 +3073,8 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
ws@8.19.0:
|
||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -3186,13 +3186,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@cacheable/utils': 2.3.3
|
||||
'@keyv/bigmap': 1.3.0(keyv@5.5.5)
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
keyv: 5.5.5
|
||||
|
||||
'@cacheable/node-cache@1.7.6':
|
||||
dependencies:
|
||||
cacheable: 2.3.1
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
keyv: 5.5.5
|
||||
|
||||
'@cacheable/utils@2.3.3':
|
||||
@ -3290,7 +3290,7 @@ snapshots:
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.37
|
||||
tslib: 2.8.1
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@ -3397,7 +3397,7 @@ snapshots:
|
||||
'@google/genai@1.34.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
@ -3548,7 +3548,7 @@ snapshots:
|
||||
'@keyv/bigmap@1.3.0(keyv@5.5.5)':
|
||||
dependencies:
|
||||
hashery: 1.4.0
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
keyv: 5.5.5
|
||||
|
||||
'@keyv/serialize@1.1.1': {}
|
||||
@ -3585,9 +3585,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@mariozechner/pi-agent-core@0.36.0(ws@8.18.3)(zod@4.3.5)':
|
||||
'@mariozechner/pi-agent-core@0.36.0(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.36.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@ -3597,7 +3597,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)':
|
||||
'@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||
'@google/genai': 1.34.0
|
||||
@ -3606,7 +3606,7 @@ snapshots:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.18.3)(zod@4.3.5)
|
||||
openai: 6.10.0(ws@8.19.0)(zod@4.3.5)
|
||||
partial-json: 0.1.7
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.5)
|
||||
transitivePeerDependencies:
|
||||
@ -3617,11 +3617,11 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.36.0(ws@8.18.3)(zod@4.3.5)':
|
||||
'@mariozechner/pi-coding-agent@0.36.0(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@crosscopy/clipboard': 0.2.8
|
||||
'@mariozechner/pi-agent-core': 0.36.0(ws@8.18.3)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
|
||||
'@mariozechner/pi-agent-core': 0.36.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.36.0
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
@ -3691,28 +3691,28 @@ snapshots:
|
||||
'@oxlint-tsgolint/win32-x64@0.10.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint/darwin-arm64@1.36.0':
|
||||
'@oxlint/darwin-arm64@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/darwin-x64@1.36.0':
|
||||
'@oxlint/darwin-x64@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/linux-arm64-gnu@1.36.0':
|
||||
'@oxlint/linux-arm64-gnu@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/linux-arm64-musl@1.36.0':
|
||||
'@oxlint/linux-arm64-musl@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/linux-x64-gnu@1.36.0':
|
||||
'@oxlint/linux-x64-gnu@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/linux-x64-musl@1.36.0':
|
||||
'@oxlint/linux-x64-musl@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/win32-arm64@1.36.0':
|
||||
'@oxlint/win32-arm64@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/win32-x64@1.36.0':
|
||||
'@oxlint/win32-x64@1.37.0':
|
||||
optional: true
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
@ -3907,7 +3907,7 @@ snapshots:
|
||||
'@types/node': 25.0.3
|
||||
'@types/ws': 8.18.1
|
||||
eventemitter3: 5.0.1
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
@ -4094,7 +4094,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
@ -4196,11 +4196,11 @@ snapshots:
|
||||
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
|
||||
lru-cache: 11.2.4
|
||||
music-metadata: 11.10.4
|
||||
p-queue: 9.0.1
|
||||
p-queue: 9.1.0
|
||||
pino: 9.14.0
|
||||
protobufjs: 7.5.4
|
||||
sharp: 0.34.5
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
optionalDependencies:
|
||||
audio-decode: 2.2.3
|
||||
transitivePeerDependencies:
|
||||
@ -4361,7 +4361,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@cacheable/memory': 2.0.7
|
||||
'@cacheable/utils': 2.3.3
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
keyv: 5.5.5
|
||||
qified: 0.5.3
|
||||
|
||||
@ -4838,7 +4838,7 @@ snapshots:
|
||||
|
||||
hashery@1.4.0:
|
||||
dependencies:
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
@ -4848,7 +4848,7 @@ snapshots:
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hookified@1.14.0: {}
|
||||
hookified@1.15.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
@ -5256,9 +5256,9 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
openai@6.10.0(ws@8.18.3)(zod@4.3.5):
|
||||
openai@6.10.0(ws@8.19.0)(zod@4.3.5):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
ws: 8.19.0
|
||||
zod: 4.3.5
|
||||
|
||||
opus-decoder@0.7.11:
|
||||
@ -5275,16 +5275,16 @@ snapshots:
|
||||
'@oxlint-tsgolint/win32-arm64': 0.10.1
|
||||
'@oxlint-tsgolint/win32-x64': 0.10.1
|
||||
|
||||
oxlint@1.36.0(oxlint-tsgolint@0.10.1):
|
||||
oxlint@1.37.0(oxlint-tsgolint@0.10.1):
|
||||
optionalDependencies:
|
||||
'@oxlint/darwin-arm64': 1.36.0
|
||||
'@oxlint/darwin-x64': 1.36.0
|
||||
'@oxlint/linux-arm64-gnu': 1.36.0
|
||||
'@oxlint/linux-arm64-musl': 1.36.0
|
||||
'@oxlint/linux-x64-gnu': 1.36.0
|
||||
'@oxlint/linux-x64-musl': 1.36.0
|
||||
'@oxlint/win32-arm64': 1.36.0
|
||||
'@oxlint/win32-x64': 1.36.0
|
||||
'@oxlint/darwin-arm64': 1.37.0
|
||||
'@oxlint/darwin-x64': 1.37.0
|
||||
'@oxlint/linux-arm64-gnu': 1.37.0
|
||||
'@oxlint/linux-arm64-musl': 1.37.0
|
||||
'@oxlint/linux-x64-gnu': 1.37.0
|
||||
'@oxlint/linux-x64-musl': 1.37.0
|
||||
'@oxlint/win32-arm64': 1.37.0
|
||||
'@oxlint/win32-x64': 1.37.0
|
||||
oxlint-tsgolint: 0.10.1
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
@ -5294,7 +5294,7 @@ snapshots:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-queue@9.0.1:
|
||||
p-queue@9.1.0:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.1
|
||||
p-timeout: 7.0.1
|
||||
@ -5453,7 +5453,7 @@ snapshots:
|
||||
|
||||
qified@0.5.3:
|
||||
dependencies:
|
||||
hookified: 1.14.0
|
||||
hookified: 1.15.0
|
||||
|
||||
qoa-format@1.0.1:
|
||||
dependencies:
|
||||
@ -5876,7 +5876,7 @@ snapshots:
|
||||
|
||||
undici@6.21.3: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
undici@7.18.0: {}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
dependencies:
|
||||
@ -5995,7 +5995,7 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
ws@8.19.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
|
||||
33
showcase.md
Normal file
33
showcase.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Showcase: what your personal assistant can do
|
||||
|
||||
Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.
|
||||
|
||||
## Automation & real-world outcomes
|
||||
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
|
||||
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
|
||||
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn (link check pending)
|
||||
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)
|
||||
|
||||
## Knowledge & memory systems
|
||||
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
|
||||
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
|
||||
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
|
||||
|
||||
## Voice, docs, and assistants on the phone
|
||||
- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge
|
||||
- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.)
|
||||
- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link)
|
||||
|
||||
## Infrastructure & deployment
|
||||
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
|
||||
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
|
||||
- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
|
||||
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
|
||||
|
||||
## Home + hardware
|
||||
- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock
|
||||
|
||||
## Community builds (non‑Clawdis but made with/around it)
|
||||
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
|
||||
|
||||
---
|
||||
49
skills/1password/SKILL.md
Normal file
49
skills/1password/SKILL.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: 1password
|
||||
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
|
||||
homepage: https://developer.1password.com/docs/cli/get-started/
|
||||
metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
|
||||
---
|
||||
|
||||
# 1Password CLI
|
||||
|
||||
Follow the official CLI get-started steps. Don't guess install commands.
|
||||
|
||||
## References
|
||||
|
||||
- `references/get-started.md` (install + app integration + sign-in flow)
|
||||
- `references/cli-examples.md` (real `op` examples)
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check OS + shell.
|
||||
2. Verify CLI present: `op --version`.
|
||||
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
|
||||
4. Sign in / authorize this terminal: `op signin` (expect an app prompt).
|
||||
5. If multiple accounts: use `--account` or `OP_ACCOUNT`.
|
||||
6. Verify access: `op whoami` or `op account list`.
|
||||
|
||||
## Avoid repeated auth prompts (tmux)
|
||||
|
||||
The bash tool uses a fresh TTY per command, so app integration may prompt every time. To reuse authorization, run multiple `op` commands inside a single tmux session.
|
||||
|
||||
Example (see `tmux` skill for socket conventions):
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/clawdbot.sock"
|
||||
SESSION=op-auth
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never paste secrets into logs, chat, or code.
|
||||
- Prefer `op run` / `op inject` over writing secrets to disk.
|
||||
- If sign-in without app integration is needed, use `op account add`.
|
||||
- If a command returns "account is not signed in", re-run `op signin` and authorize in the app.
|
||||
29
skills/1password/references/cli-examples.md
Normal file
29
skills/1password/references/cli-examples.md
Normal file
@ -0,0 +1,29 @@
|
||||
# op CLI examples (from op help)
|
||||
|
||||
## Sign in
|
||||
|
||||
- `op signin`
|
||||
- `op signin --account <shorthand|signin-address|account-id|user-id>`
|
||||
|
||||
## Read
|
||||
|
||||
- `op read op://app-prod/db/password`
|
||||
- `op read "op://app-prod/db/one-time password?attribute=otp"`
|
||||
- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"`
|
||||
- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem`
|
||||
|
||||
## Run
|
||||
|
||||
- `export DB_PASSWORD="op://app-prod/db/password"`
|
||||
- `op run --no-masking -- printenv DB_PASSWORD`
|
||||
- `op run --env-file="./.env" -- printenv DB_PASSWORD`
|
||||
|
||||
## Inject
|
||||
|
||||
- `echo "db_password: {{ op://app-prod/db/password }}" | op inject`
|
||||
- `op inject -i config.yml.tpl -o config.yml`
|
||||
|
||||
## Whoami / accounts
|
||||
|
||||
- `op whoami`
|
||||
- `op account list`
|
||||
17
skills/1password/references/get-started.md
Normal file
17
skills/1password/references/get-started.md
Normal file
@ -0,0 +1,17 @@
|
||||
# 1Password CLI get-started (summary)
|
||||
|
||||
- Works on macOS, Windows, and Linux.
|
||||
- macOS/Linux shells: bash, zsh, sh, fish.
|
||||
- Windows shell: PowerShell.
|
||||
- Requires a 1Password subscription and the desktop app to use app integration.
|
||||
- macOS requirement: Big Sur 11.0.0 or later.
|
||||
- Linux app integration requires PolKit + an auth agent.
|
||||
- Install the CLI per the official doc for your OS.
|
||||
- Enable desktop app integration in the 1Password app:
|
||||
- Open and unlock the app, then select your account/collection.
|
||||
- macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional).
|
||||
- Windows: turn on Windows Hello, then Settings > Developer > Integrate.
|
||||
- Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate.
|
||||
- After integration, run any command to sign in (example in docs: `op vault list`).
|
||||
- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`.
|
||||
- For non-integration auth, use `op account add`.
|
||||
77
src/agents/pi-embedded-helpers.test.ts
Normal file
77
src/agents/pi-embedded-helpers.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import {
|
||||
isRateLimitAssistantError,
|
||||
pickFallbackThinkingLevel,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
|
||||
const asAssistant = (overrides: Partial<AssistantMessage>) =>
|
||||
({
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
...overrides,
|
||||
}) as AssistantMessage;
|
||||
|
||||
describe("isRateLimitAssistantError", () => {
|
||||
it("detects 429 rate limit payloads", () => {
|
||||
const msg = asAssistant({
|
||||
errorMessage:
|
||||
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
|
||||
});
|
||||
expect(isRateLimitAssistantError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects human-readable rate limit messages", () => {
|
||||
const msg = asAssistant({
|
||||
errorMessage: "Too many requests. Rate limit exceeded.",
|
||||
});
|
||||
expect(isRateLimitAssistantError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects quota exceeded messages", () => {
|
||||
const msg = asAssistant({
|
||||
errorMessage:
|
||||
"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
|
||||
});
|
||||
expect(isRateLimitAssistantError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-error messages", () => {
|
||||
const msg = asAssistant({
|
||||
stopReason: "end_turn",
|
||||
errorMessage: "rate limit",
|
||||
});
|
||||
expect(isRateLimitAssistantError(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickFallbackThinkingLevel", () => {
|
||||
it("selects the first supported thinking level", () => {
|
||||
const attempted = new Set<ThinkLevel>(["low"]);
|
||||
const next = pickFallbackThinkingLevel({
|
||||
message:
|
||||
"Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.",
|
||||
attempted,
|
||||
});
|
||||
expect(next).toBe("medium");
|
||||
});
|
||||
|
||||
it("skips already attempted levels", () => {
|
||||
const attempted = new Set<ThinkLevel>(["low", "medium"]);
|
||||
const next = pickFallbackThinkingLevel({
|
||||
message: "Supported values are: 'medium', 'high', and 'xhigh'.",
|
||||
attempted,
|
||||
});
|
||||
expect(next).toBe("high");
|
||||
});
|
||||
|
||||
it("returns undefined when no supported values are found", () => {
|
||||
const attempted = new Set<ThinkLevel>(["low"]);
|
||||
const next = pickFallbackThinkingLevel({
|
||||
message: "Request failed.",
|
||||
attempted,
|
||||
});
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,10 @@ import type {
|
||||
AgentToolResult,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
normalizeThinkLevel,
|
||||
type ThinkLevel,
|
||||
} from "../auto-reply/thinking.js";
|
||||
|
||||
import { sanitizeContentBlocksImages } from "./tool-images.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
@ -109,3 +113,50 @@ export function formatAssistantErrorText(
|
||||
// Keep it short for WhatsApp.
|
||||
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
|
||||
}
|
||||
|
||||
export function isRateLimitAssistantError(
|
||||
msg: AssistantMessage | undefined,
|
||||
): boolean {
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
const raw = (msg.errorMessage ?? "").toLowerCase();
|
||||
if (!raw) return false;
|
||||
return (
|
||||
/rate[_ ]limit|too many requests|429/.test(raw) ||
|
||||
raw.includes("exceeded your current quota")
|
||||
);
|
||||
}
|
||||
|
||||
function extractSupportedValues(raw: string): string[] {
|
||||
const match =
|
||||
raw.match(/supported values are:\s*([^\n.]+)/i) ??
|
||||
raw.match(/supported values:\s*([^\n.]+)/i);
|
||||
if (!match?.[1]) return [];
|
||||
const fragment = match[1];
|
||||
const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map(
|
||||
(entry) => entry[1]?.trim(),
|
||||
);
|
||||
if (quoted.length > 0) {
|
||||
return quoted.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
return fragment
|
||||
.split(/,|\band\b/gi)
|
||||
.map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function pickFallbackThinkingLevel(params: {
|
||||
message?: string;
|
||||
attempted: Set<ThinkLevel>;
|
||||
}): ThinkLevel | undefined {
|
||||
const raw = params.message?.trim();
|
||||
if (!raw) return undefined;
|
||||
const supported = extractSupportedValues(raw);
|
||||
if (supported.length === 0) return undefined;
|
||||
for (const entry of supported) {
|
||||
const normalized = normalizeThinkLevel(entry);
|
||||
if (!normalized) continue;
|
||||
if (params.attempted.has(normalized)) continue;
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -32,6 +32,8 @@ import {
|
||||
buildBootstrapContextFiles,
|
||||
ensureSessionHeader,
|
||||
formatAssistantErrorText,
|
||||
isRateLimitAssistantError,
|
||||
pickFallbackThinkingLevel,
|
||||
sanitizeSessionMessagesImages,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
@ -114,6 +116,57 @@ function resolveGlobalLane(lane?: string) {
|
||||
return cleaned ? cleaned : "main";
|
||||
}
|
||||
|
||||
function resolveUserTimezone(configured?: string): string {
|
||||
const trimmed = configured?.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
|
||||
new Date(),
|
||||
);
|
||||
return trimmed;
|
||||
} catch {
|
||||
// ignore invalid timezone
|
||||
}
|
||||
}
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return host?.trim() || "UTC";
|
||||
}
|
||||
|
||||
function formatUserTime(date: Date, timeZone: string): string | undefined {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
if (!map.year || !map.month || !map.day || !map.hour || !map.minute) {
|
||||
return undefined;
|
||||
}
|
||||
return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function describeUnknownError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
try {
|
||||
const serialized = JSON.stringify(error);
|
||||
return serialized ?? "Unknown error";
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEmbeddedSandboxInfo(
|
||||
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
|
||||
): EmbeddedSandboxInfo | undefined {
|
||||
@ -318,311 +371,364 @@ export async function runEmbeddedPiAgent(params: {
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
|
||||
const thinkingLevel = mapThinkingLevel(params.thinkLevel);
|
||||
let thinkLevel = params.thinkLevel ?? "off";
|
||||
const attemptedThinking = new Set<ThinkLevel>();
|
||||
|
||||
log.debug(
|
||||
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`,
|
||||
);
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: resolvedWorkspace,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(resolvedWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries =
|
||||
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(resolvedWorkspace)
|
||||
: [];
|
||||
const skillsSnapshot =
|
||||
params.skillsSnapshot ??
|
||||
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
|
||||
config: params.config,
|
||||
entries: skillEntries,
|
||||
});
|
||||
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
const bootstrapFiles =
|
||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
|
||||
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
|
||||
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
|
||||
const tools = createClawdbotCodingTools({
|
||||
bash: {
|
||||
...params.config?.agent?.bash,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${provider}/${modelId}`,
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
|
||||
const reasoningTagHint = provider === "ollama";
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
appendPrompt: buildAgentSystemPromptAppend({
|
||||
workspaceDir: resolvedWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
toolNames: tools.map((tool) => tool.name),
|
||||
}),
|
||||
contextFiles,
|
||||
skills: promptSkills,
|
||||
cwd: resolvedWorkspace,
|
||||
tools,
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(
|
||||
resolvedWorkspace,
|
||||
agentDir,
|
||||
);
|
||||
|
||||
// Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific)
|
||||
const builtInToolNames = new Set(["read", "bash", "edit", "write"]);
|
||||
const builtInTools = tools.filter((t) => builtInToolNames.has(t.name));
|
||||
const customTools = toToolDefinitions(
|
||||
tools.filter((t) => !builtInToolNames.has(t.name)),
|
||||
);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel,
|
||||
systemPrompt,
|
||||
// Built-in tools recognized by pi-coding-agent SDK
|
||||
tools: builtInTools,
|
||||
// Custom clawdbot tools (browser, canvas, nodes, cron, etc.)
|
||||
customTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
skills: promptSkills,
|
||||
contextFiles,
|
||||
});
|
||||
|
||||
const prior = await sanitizeSessionMessagesImages(
|
||||
session.messages,
|
||||
"session:history",
|
||||
);
|
||||
if (prior.length > 0) {
|
||||
session.agent.replaceMessages(prior);
|
||||
}
|
||||
let aborted = Boolean(params.abortSignal?.aborted);
|
||||
const abortRun = () => {
|
||||
aborted = true;
|
||||
void session.abort();
|
||||
};
|
||||
const queueHandle: EmbeddedPiQueueHandle = {
|
||||
queueMessage: async (text: string) => {
|
||||
await session.steer(text);
|
||||
},
|
||||
isStreaming: () => session.isStreaming,
|
||||
abort: abortRun,
|
||||
};
|
||||
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
|
||||
|
||||
const {
|
||||
assistantTexts,
|
||||
toolMetas,
|
||||
unsubscribe,
|
||||
waitForCompactionRetry,
|
||||
} = subscribeEmbeddedPiSession({
|
||||
session,
|
||||
runId: params.runId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
onToolResult: params.onToolResult,
|
||||
onBlockReply: params.onBlockReply,
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
});
|
||||
|
||||
let abortWarnTimer: NodeJS.Timeout | undefined;
|
||||
const abortTimer = setTimeout(
|
||||
() => {
|
||||
log.warn(
|
||||
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
||||
);
|
||||
abortRun();
|
||||
if (!abortWarnTimer) {
|
||||
abortWarnTimer = setTimeout(() => {
|
||||
if (!session.isStreaming) return;
|
||||
log.warn(
|
||||
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
}, 10_000);
|
||||
}
|
||||
},
|
||||
Math.max(1, params.timeoutMs),
|
||||
);
|
||||
|
||||
let messagesSnapshot: AgentMessage[] = [];
|
||||
let sessionIdUsed = session.sessionId;
|
||||
const onAbort = () => {
|
||||
abortRun();
|
||||
};
|
||||
if (params.abortSignal) {
|
||||
if (params.abortSignal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
params.abortSignal.addEventListener("abort", onAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
let promptError: unknown = null;
|
||||
try {
|
||||
const promptStartedAt = Date.now();
|
||||
log.debug(
|
||||
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
try {
|
||||
await session.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
promptError = err;
|
||||
} finally {
|
||||
log.debug(
|
||||
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
|
||||
);
|
||||
}
|
||||
await waitForCompactionRetry();
|
||||
messagesSnapshot = session.messages.slice();
|
||||
sessionIdUsed = session.sessionId;
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
if (abortWarnTimer) {
|
||||
clearTimeout(abortWarnTimer);
|
||||
abortWarnTimer = undefined;
|
||||
}
|
||||
unsubscribe();
|
||||
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
|
||||
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
|
||||
notifyEmbeddedRunEnded(params.sessionId);
|
||||
}
|
||||
session.dispose();
|
||||
params.abortSignal?.removeEventListener?.("abort", onAbort);
|
||||
}
|
||||
if (promptError && !aborted) {
|
||||
throw promptError;
|
||||
}
|
||||
|
||||
const lastAssistant = messagesSnapshot
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => (m as AgentMessage)?.role === "assistant") as
|
||||
| AssistantMessage
|
||||
| undefined;
|
||||
|
||||
const usage = lastAssistant?.usage;
|
||||
const agentMeta: EmbeddedPiAgentMeta = {
|
||||
sessionId: sessionIdUsed,
|
||||
provider: lastAssistant?.provider ?? provider,
|
||||
model: lastAssistant?.model ?? model.id,
|
||||
usage: usage
|
||||
? {
|
||||
input: usage.input,
|
||||
output: usage.output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
total: usage.totalTokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const replyItems: Array<{ text: string; media?: string[] }> = [];
|
||||
|
||||
const errorText = lastAssistant
|
||||
? formatAssistantErrorText(lastAssistant)
|
||||
: undefined;
|
||||
if (errorText) replyItems.push({ text: errorText });
|
||||
|
||||
const inlineToolResults =
|
||||
params.verboseLevel === "on" &&
|
||||
!params.onPartialReply &&
|
||||
!params.onToolResult &&
|
||||
toolMetas.length > 0;
|
||||
if (inlineToolResults) {
|
||||
for (const { toolName, meta } of toolMetas) {
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
|
||||
if (cleanedText)
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of assistantTexts.length
|
||||
? assistantTexts
|
||||
: lastAssistant
|
||||
? [extractAssistantText(lastAssistant)]
|
||||
: []) {
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue;
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
|
||||
const payloads = replyItems
|
||||
.map((item) => ({
|
||||
text: item.text?.trim() ? item.text.trim() : undefined,
|
||||
mediaUrls: item.media?.length ? item.media : undefined,
|
||||
mediaUrl: item.media?.[0],
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
|
||||
);
|
||||
while (true) {
|
||||
const thinkingLevel = mapThinkingLevel(thinkLevel);
|
||||
attemptedThinking.add(thinkLevel);
|
||||
|
||||
log.debug(
|
||||
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
|
||||
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`,
|
||||
);
|
||||
return {
|
||||
payloads: payloads.length ? payloads : undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta,
|
||||
aborted,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: resolvedWorkspace,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(resolvedWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries =
|
||||
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(resolvedWorkspace)
|
||||
: [];
|
||||
const skillsSnapshot =
|
||||
params.skillsSnapshot ??
|
||||
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
|
||||
config: params.config,
|
||||
entries: skillEntries,
|
||||
});
|
||||
const sandboxSessionKey =
|
||||
params.sessionKey?.trim() || params.sessionId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
const bootstrapFiles =
|
||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const promptSkills = resolvePromptSkills(
|
||||
skillsSnapshot,
|
||||
skillEntries,
|
||||
);
|
||||
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
|
||||
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
|
||||
const tools = createClawdbotCodingTools({
|
||||
bash: {
|
||||
...params.config?.agent?.bash,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${provider}/${modelId}`,
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
|
||||
const reasoningTagHint = provider === "ollama";
|
||||
const userTimezone = resolveUserTimezone(
|
||||
params.config?.agent?.userTimezone,
|
||||
);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
appendPrompt: buildAgentSystemPromptAppend({
|
||||
workspaceDir: resolvedWorkspace,
|
||||
defaultThinkLevel: thinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
toolNames: tools.map((tool) => tool.name),
|
||||
userTimezone,
|
||||
userTime,
|
||||
}),
|
||||
contextFiles,
|
||||
skills: promptSkills,
|
||||
cwd: resolvedWorkspace,
|
||||
tools,
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(
|
||||
resolvedWorkspace,
|
||||
agentDir,
|
||||
);
|
||||
|
||||
// Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific)
|
||||
const builtInToolNames = new Set(["read", "bash", "edit", "write"]);
|
||||
const builtInTools = tools.filter((t) =>
|
||||
builtInToolNames.has(t.name),
|
||||
);
|
||||
const customTools = toToolDefinitions(
|
||||
tools.filter((t) => !builtInToolNames.has(t.name)),
|
||||
);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel,
|
||||
systemPrompt,
|
||||
// Built-in tools recognized by pi-coding-agent SDK
|
||||
tools: builtInTools,
|
||||
// Custom clawdbot tools (browser, canvas, nodes, cron, etc.)
|
||||
customTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
skills: promptSkills,
|
||||
contextFiles,
|
||||
});
|
||||
|
||||
const prior = await sanitizeSessionMessagesImages(
|
||||
session.messages,
|
||||
"session:history",
|
||||
);
|
||||
if (prior.length > 0) {
|
||||
session.agent.replaceMessages(prior);
|
||||
}
|
||||
let aborted = Boolean(params.abortSignal?.aborted);
|
||||
const abortRun = () => {
|
||||
aborted = true;
|
||||
void session.abort();
|
||||
};
|
||||
const queueHandle: EmbeddedPiQueueHandle = {
|
||||
queueMessage: async (text: string) => {
|
||||
await session.steer(text);
|
||||
},
|
||||
isStreaming: () => session.isStreaming,
|
||||
abort: abortRun,
|
||||
};
|
||||
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
|
||||
|
||||
const {
|
||||
assistantTexts,
|
||||
toolMetas,
|
||||
unsubscribe,
|
||||
waitForCompactionRetry,
|
||||
} = subscribeEmbeddedPiSession({
|
||||
session,
|
||||
runId: params.runId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
onToolResult: params.onToolResult,
|
||||
onBlockReply: params.onBlockReply,
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
});
|
||||
|
||||
let abortWarnTimer: NodeJS.Timeout | undefined;
|
||||
const abortTimer = setTimeout(
|
||||
() => {
|
||||
log.warn(
|
||||
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
||||
);
|
||||
abortRun();
|
||||
if (!abortWarnTimer) {
|
||||
abortWarnTimer = setTimeout(() => {
|
||||
if (!session.isStreaming) return;
|
||||
log.warn(
|
||||
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
}, 10_000);
|
||||
}
|
||||
},
|
||||
Math.max(1, params.timeoutMs),
|
||||
);
|
||||
|
||||
let messagesSnapshot: AgentMessage[] = [];
|
||||
let sessionIdUsed = session.sessionId;
|
||||
const onAbort = () => {
|
||||
abortRun();
|
||||
};
|
||||
if (params.abortSignal) {
|
||||
if (params.abortSignal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
params.abortSignal.addEventListener("abort", onAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
let promptError: unknown = null;
|
||||
try {
|
||||
const promptStartedAt = Date.now();
|
||||
log.debug(
|
||||
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
try {
|
||||
await session.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
promptError = err;
|
||||
} finally {
|
||||
log.debug(
|
||||
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
|
||||
);
|
||||
}
|
||||
await waitForCompactionRetry();
|
||||
messagesSnapshot = session.messages.slice();
|
||||
sessionIdUsed = session.sessionId;
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
if (abortWarnTimer) {
|
||||
clearTimeout(abortWarnTimer);
|
||||
abortWarnTimer = undefined;
|
||||
}
|
||||
unsubscribe();
|
||||
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
|
||||
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
|
||||
notifyEmbeddedRunEnded(params.sessionId);
|
||||
}
|
||||
session.dispose();
|
||||
params.abortSignal?.removeEventListener?.("abort", onAbort);
|
||||
}
|
||||
if (promptError && !aborted) {
|
||||
const fallbackThinking = pickFallbackThinkingLevel({
|
||||
message: describeUnknownError(promptError),
|
||||
attempted: attemptedThinking,
|
||||
});
|
||||
if (fallbackThinking) {
|
||||
log.warn(
|
||||
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
|
||||
);
|
||||
thinkLevel = fallbackThinking;
|
||||
continue;
|
||||
}
|
||||
throw promptError;
|
||||
}
|
||||
|
||||
const lastAssistant = messagesSnapshot
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => (m as AgentMessage)?.role === "assistant") as
|
||||
| AssistantMessage
|
||||
| undefined;
|
||||
|
||||
const fallbackThinking = pickFallbackThinkingLevel({
|
||||
message: lastAssistant?.errorMessage,
|
||||
attempted: attemptedThinking,
|
||||
});
|
||||
if (fallbackThinking && !aborted) {
|
||||
log.warn(
|
||||
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
|
||||
);
|
||||
thinkLevel = fallbackThinking;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0;
|
||||
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) {
|
||||
const message =
|
||||
lastAssistant?.errorMessage?.trim() ||
|
||||
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") ||
|
||||
"LLM request rate limited.";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const usage = lastAssistant?.usage;
|
||||
const agentMeta: EmbeddedPiAgentMeta = {
|
||||
sessionId: sessionIdUsed,
|
||||
provider: lastAssistant?.provider ?? provider,
|
||||
model: lastAssistant?.model ?? model.id,
|
||||
usage: usage
|
||||
? {
|
||||
input: usage.input,
|
||||
output: usage.output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
total: usage.totalTokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const replyItems: Array<{ text: string; media?: string[] }> = [];
|
||||
|
||||
const errorText = lastAssistant
|
||||
? formatAssistantErrorText(lastAssistant)
|
||||
: undefined;
|
||||
if (errorText) replyItems.push({ text: errorText });
|
||||
|
||||
const inlineToolResults =
|
||||
params.verboseLevel === "on" &&
|
||||
!params.onPartialReply &&
|
||||
!params.onToolResult &&
|
||||
toolMetas.length > 0;
|
||||
if (inlineToolResults) {
|
||||
for (const { toolName, meta } of toolMetas) {
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
|
||||
const { text: cleanedText, mediaUrls } =
|
||||
splitMediaFromOutput(agg);
|
||||
if (cleanedText)
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of assistantTexts.length
|
||||
? assistantTexts
|
||||
: lastAssistant
|
||||
? [extractAssistantText(lastAssistant)]
|
||||
: []) {
|
||||
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
|
||||
continue;
|
||||
replyItems.push({ text: cleanedText, media: mediaUrls });
|
||||
}
|
||||
|
||||
const payloads = replyItems
|
||||
.map((item) => ({
|
||||
text: item.text?.trim() ? item.text.trim() : undefined,
|
||||
mediaUrls: item.media?.length ? item.media : undefined,
|
||||
mediaUrl: item.media?.[0],
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
log.debug(
|
||||
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
|
||||
);
|
||||
return {
|
||||
payloads: payloads.length ? payloads : undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta,
|
||||
aborted,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@ -46,4 +46,16 @@ describe("buildAgentSystemPromptAppend", () => {
|
||||
expect(prompt).toContain("sessions_send");
|
||||
expect(prompt).toContain("Unavailable tools (do not call):");
|
||||
});
|
||||
|
||||
it("includes user time when provided", () => {
|
||||
const prompt = buildAgentSystemPromptAppend({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
userTime: "2026-01-05 15:26",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Time");
|
||||
expect(prompt).toContain("User timezone: America/Chicago");
|
||||
expect(prompt).toContain("Current user time: 2026-01-05 15:26");
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,6 +7,8 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
ownerNumbers?: string[];
|
||||
reasoningTagHint?: boolean;
|
||||
toolNames?: string[];
|
||||
userTimezone?: string;
|
||||
userTime?: string;
|
||||
runtimeInfo?: {
|
||||
host?: string;
|
||||
os?: string;
|
||||
@ -109,6 +111,8 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
"<final>Hey there! What would you like to do next?</final>",
|
||||
].join(" ")
|
||||
: undefined;
|
||||
const userTimezone = params.userTimezone?.trim();
|
||||
const userTime = params.userTime?.trim();
|
||||
const runtimeInfo = params.runtimeInfo;
|
||||
const runtimeLines: string[] = [];
|
||||
if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`);
|
||||
@ -182,6 +186,10 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.",
|
||||
"Clawdbot handles message transport automatically; respond normally and your reply will be delivered to the current chat.",
|
||||
"",
|
||||
userTimezone || userTime ? "## Time" : "",
|
||||
userTimezone ? `User timezone: ${userTimezone}` : "",
|
||||
userTime ? `Current user time: ${userTime}` : "",
|
||||
userTimezone || userTime ? "" : "",
|
||||
"## Reply Tags",
|
||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||
"- [[reply_to_current]] replies to the triggering message.",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import { redactToolDetail } from "../logging/redact.js";
|
||||
import { shortenHomeInString } from "../utils.js";
|
||||
|
||||
type ToolDisplayActionSpec = {
|
||||
@ -193,7 +194,7 @@ export function resolveToolDisplay(params: {
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (display.verb) parts.push(display.verb);
|
||||
if (display.detail) parts.push(display.detail);
|
||||
if (display.detail) parts.push(redactToolDetail(display.detail));
|
||||
if (parts.length === 0) return undefined;
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
@ -19,12 +19,12 @@ describe("formatAgentEnvelope", () => {
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toMatch(
|
||||
/^\[WebChat user1 mac-mini 10\.0\.0\.5 2025-01-02T03:04\+00:00\{.+\}\] hello$/,
|
||||
expect(body).toBe(
|
||||
"[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats timestamps in local time (not UTC)", () => {
|
||||
it("formats timestamps in UTC regardless of local timezone", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
@ -37,9 +37,7 @@ describe("formatAgentEnvelope", () => {
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toBe(
|
||||
"[WebChat 2025-01-01T19:04-08:00{America/Los_Angeles}] hello",
|
||||
);
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("handles missing optional fields", () => {
|
||||
|
||||
@ -12,25 +12,15 @@ function formatTimestamp(ts?: number | Date): string | undefined {
|
||||
const date = ts instanceof Date ? ts : new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
const yyyy = String(date.getFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
|
||||
// getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset.
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
|
||||
const offsetM = String(absOffsetMinutes % 60).padStart(2, "0");
|
||||
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const tzSuffix = tz ? `{${tz}}` : "";
|
||||
|
||||
// Compact ISO-like *local* timestamp with minutes precision.
|
||||
// Example: 2025-01-02T03:04-08:00{America/Los_Angeles}
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`;
|
||||
// Compact ISO-like UTC timestamp with minutes precision.
|
||||
// Example: 2025-01-02T03:04Z
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
|
||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 30;
|
||||
|
||||
export type StripHeartbeatMode = "heartbeat" | "message";
|
||||
|
||||
@ -44,7 +45,10 @@ export function stripHeartbeatToken(
|
||||
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
|
||||
|
||||
const mode: StripHeartbeatMode = opts.mode ?? "message";
|
||||
const maxAckChars = Math.max(0, opts.maxAckChars ?? 30);
|
||||
const maxAckChars = Math.max(
|
||||
0,
|
||||
opts.maxAckChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
|
||||
if (!trimmed.includes(HEARTBEAT_TOKEN)) {
|
||||
return { shouldSkip: false, text: trimmed, didStrip: false };
|
||||
|
||||
@ -636,6 +636,31 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not repeat missing auth labels on /model list", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
allowedModels: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("auth:");
|
||||
expect(text).not.toContain("missing (missing)");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sets model override on /model directive", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@ -93,6 +93,13 @@ const resolveAuthLabel = async (
|
||||
return { label: "missing", source: "missing" };
|
||||
};
|
||||
|
||||
const formatAuthLabel = (auth: { label: string; source: string }) => {
|
||||
if (!auth.source || auth.source === auth.label || auth.source === "missing") {
|
||||
return auth.label;
|
||||
}
|
||||
return `${auth.label} (${auth.source})`;
|
||||
};
|
||||
|
||||
export type InlineDirectives = {
|
||||
cleaned: string;
|
||||
hasThinkDirective: boolean;
|
||||
@ -272,7 +279,7 @@ export async function handleDirectiveOnly(params: {
|
||||
authStorage,
|
||||
authPaths,
|
||||
);
|
||||
authByProvider.set(entry.provider, `${auth.label} (${auth.source})`);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const defaultLabel = `${defaultProvider}/${defaultModel}`;
|
||||
|
||||
@ -42,8 +42,8 @@ export async function ensureSystemdUserLingerInteractive(params: {
|
||||
params.reason ??
|
||||
"Systemd user services stop when you log out or go idle, which kills the Gateway.";
|
||||
const actionNote = params.requireConfirm
|
||||
? "We can enable lingering now (needs sudo; writes /var/lib/systemd/linger)."
|
||||
: "Enabling lingering now (needs sudo; writes /var/lib/systemd/linger).";
|
||||
? "We can enable lingering now (may require sudo; writes /var/lib/systemd/linger)."
|
||||
: "Enabling lingering now (may require sudo; writes /var/lib/systemd/linger).";
|
||||
await prompter.note(`${reason}\n${actionNote}`, title);
|
||||
|
||||
if (params.requireConfirm && prompter.confirm) {
|
||||
@ -60,6 +60,15 @@ export async function ensureSystemdUserLingerInteractive(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const resultNoSudo = await enableSystemdUserLinger({
|
||||
env,
|
||||
user: status.user,
|
||||
});
|
||||
if (resultNoSudo.ok) {
|
||||
await prompter.note(`Enabled systemd lingering for ${status.user}.`, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await enableSystemdUserLinger({
|
||||
env,
|
||||
user: status.user,
|
||||
|
||||
@ -142,6 +142,19 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const logging = cfg.logging;
|
||||
if (!logging) return cfg;
|
||||
if (logging.redactSensitive) return cfg;
|
||||
return {
|
||||
...cfg,
|
||||
logging: {
|
||||
...logging,
|
||||
redactSensitive: "tools",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSessionDefaultsWarningForTests() {
|
||||
defaultWarnState = { warned: false };
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "../infra/shell-env.js";
|
||||
import {
|
||||
applyIdentityDefaults,
|
||||
applyLoggingDefaults,
|
||||
applyModelAliasDefaults,
|
||||
applySessionDefaults,
|
||||
applyTalkApiKey,
|
||||
@ -115,7 +116,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}
|
||||
const cfg = applyModelAliasDefaults(
|
||||
applySessionDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||
applyLoggingDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -201,7 +204,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: applyTalkApiKey(
|
||||
applyModelAliasDefaults(applySessionDefaults(validated.config)),
|
||||
applyModelAliasDefaults(
|
||||
applySessionDefaults(applyLoggingDefaults(validated.config)),
|
||||
),
|
||||
),
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
|
||||
@ -44,6 +44,10 @@ export type LoggingConfig = {
|
||||
| "debug"
|
||||
| "trace";
|
||||
consoleStyle?: "pretty" | "compact" | "json";
|
||||
/** Redact sensitive tokens in tool summaries. Default: "tools". */
|
||||
redactSensitive?: "off" | "tools";
|
||||
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
|
||||
redactPatterns?: string[];
|
||||
};
|
||||
|
||||
export type WebReconnectConfig = {
|
||||
@ -445,7 +449,6 @@ export type RoutingConfig = {
|
||||
export type MessagesConfig = {
|
||||
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "")
|
||||
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
|
||||
timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC)
|
||||
};
|
||||
|
||||
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
|
||||
@ -672,6 +675,8 @@ export type ClawdbotConfig = {
|
||||
imageModel?: string;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Optional allowlist for /model (provider/model or model-only). */
|
||||
allowedModels?: string[];
|
||||
/** Optional model aliases for /model (alias -> provider/model). */
|
||||
@ -726,6 +731,8 @@ export type ClawdbotConfig = {
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
|
||||
prompt?: string;
|
||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||
ackMaxChars?: number;
|
||||
};
|
||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||
maxConcurrent?: number;
|
||||
|
||||
@ -150,7 +150,6 @@ const MessagesSchema = z
|
||||
.object({
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@ -172,6 +171,7 @@ const HeartbeatSchema = z
|
||||
.optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
ackMaxChars: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val.every) return;
|
||||
@ -330,6 +330,10 @@ export const ClawdbotSchema = z.object({
|
||||
consoleStyle: z
|
||||
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
|
||||
.optional(),
|
||||
redactSensitive: z
|
||||
.union([z.literal("off"), z.literal("tools")])
|
||||
.optional(),
|
||||
redactPatterns: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
browser: z
|
||||
@ -375,6 +379,7 @@ export const ClawdbotSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
imageModel: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
allowedModels: z.array(z.string()).optional(),
|
||||
modelAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelFallbacks: z.array(z.string()).optional(),
|
||||
|
||||
@ -377,4 +377,224 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response is exactly HEARTBEAT_OK", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
// Job still succeeds, but no delivery happens.
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.summary).toBe("HEARTBEAT_OK");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response has HEARTBEAT_OK with short padding", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({
|
||||
messageId: "w1",
|
||||
chatId: "+1234",
|
||||
}),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, { whatsapp: { allowFrom: ["+1234"] } }),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+1234",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but also substantial content", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Long content after HEARTBEAT_OK should still be delivered.
|
||||
const longContent = `Important alert: ${"a".repeat(50)}`;
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: `HEARTBEAT_OK ${longContent}` }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but includes media", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Media should still be delivered even if text is just HEARTBEAT_OK.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [
|
||||
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
|
||||
],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"HEARTBEAT_OK",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when heartbeat ack padding exceeds configured limit", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = makeCfg(home, storePath);
|
||||
cfg.agent = { ...cfg.agent, heartbeat: { ackMaxChars: 0 } };
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,6 +18,10 @@ import {
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
@ -57,6 +61,28 @@ function pickSummaryFromPayloads(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
*/
|
||||
function isHeartbeatOnlyResponse(
|
||||
payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>,
|
||||
ackMaxChars: number,
|
||||
) {
|
||||
if (payloads.length === 0) return true;
|
||||
return payloads.every((payload) => {
|
||||
// If there's media, we should deliver regardless of text content.
|
||||
const hasMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl);
|
||||
if (hasMedia) return false;
|
||||
// Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack.
|
||||
const result = stripHeartbeatToken(payload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
return result.shouldSkip;
|
||||
});
|
||||
}
|
||||
function resolveDeliveryTarget(
|
||||
cfg: ClawdbotConfig,
|
||||
jobPayload: {
|
||||
@ -343,7 +369,15 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
const summary =
|
||||
pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
|
||||
if (delivery) {
|
||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||
// This allows cron jobs to silently ack when nothing to report but still deliver
|
||||
// actual content when there is something to say.
|
||||
const ackMaxChars =
|
||||
params.cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
||||
const skipHeartbeatDelivery =
|
||||
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
||||
|
||||
if (delivery && !skipHeartbeatDelivery) {
|
||||
if (resolvedDelivery.channel === "whatsapp") {
|
||||
if (!resolvedDelivery.to) {
|
||||
if (!bestEffortDeliver)
|
||||
|
||||
@ -31,12 +31,11 @@ vi.mock("../config/sessions.js", () => ({
|
||||
|
||||
vi.mock("discord.js", () => {
|
||||
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
let lastClient: Client | null = null;
|
||||
|
||||
class Client {
|
||||
static lastClient: Client | null = null;
|
||||
user = { id: "bot-id", tag: "bot#1" };
|
||||
constructor() {
|
||||
lastClient = this;
|
||||
Client.lastClient = this;
|
||||
}
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
@ -50,7 +49,7 @@ vi.mock("discord.js", () => {
|
||||
}
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
void handler(...args);
|
||||
void Promise.resolve(handler(...args));
|
||||
}
|
||||
}
|
||||
login = vi.fn().mockResolvedValue(undefined);
|
||||
@ -59,7 +58,7 @@ vi.mock("discord.js", () => {
|
||||
|
||||
return {
|
||||
Client,
|
||||
__getLastClient: () => lastClient,
|
||||
__getLastClient: () => Client.lastClient,
|
||||
Events: {
|
||||
ClientReady: "ready",
|
||||
Error: "error",
|
||||
|
||||
@ -181,6 +181,64 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("respects ackMaxChars for heartbeat acks", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agent: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
ackMaxChars: 0,
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips WhatsApp delivery when not linked or running", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
HEARTBEAT_PROMPT,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
@ -102,6 +103,13 @@ export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
}
|
||||
|
||||
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
|
||||
return Math.max(
|
||||
0,
|
||||
cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHeartbeatSession(cfg: ClawdbotConfig) {
|
||||
const sessionCfg = cfg.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
@ -277,11 +285,12 @@ async function restoreHeartbeatUpdatedAt(params: {
|
||||
|
||||
function normalizeHeartbeatReply(
|
||||
payload: ReplyPayload,
|
||||
responsePrefix?: string,
|
||||
responsePrefix: string | undefined,
|
||||
ackMaxChars: number,
|
||||
) {
|
||||
const stripped = stripHeartbeatToken(payload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: 30,
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
@ -478,9 +487,11 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
}
|
||||
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
|
||||
const normalized = normalizeHeartbeatReply(
|
||||
replyPayload,
|
||||
cfg.messages?.responsePrefix,
|
||||
ackMaxChars,
|
||||
);
|
||||
if (normalized.shouldSkip && !normalized.hasMedia) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
|
||||
99
src/logging/redact.test.ts
Normal file
99
src/logging/redact.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getDefaultRedactPatterns, redactSensitiveText } from "./redact.js";
|
||||
|
||||
const defaults = getDefaultRedactPatterns();
|
||||
|
||||
describe("redactSensitiveText", () => {
|
||||
it("masks env assignments while keeping the key", () => {
|
||||
const input = "OPENAI_API_KEY=sk-1234567890abcdef";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe("OPENAI_API_KEY=sk-123…cdef");
|
||||
});
|
||||
|
||||
it("masks CLI flags", () => {
|
||||
const input = "curl --token abcdef1234567890ghij https://api.test";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe("curl --token abcdef…ghij https://api.test");
|
||||
});
|
||||
|
||||
it("masks JSON fields", () => {
|
||||
const input = '{"token":"abcdef1234567890ghij"}';
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe('{"token":"abcdef…ghij"}');
|
||||
});
|
||||
|
||||
it("masks bearer tokens", () => {
|
||||
const input = "Authorization: Bearer abcdef1234567890ghij";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe("Authorization: Bearer abcdef…ghij");
|
||||
});
|
||||
|
||||
it("masks Telegram-style tokens", () => {
|
||||
const input = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe("123456…cdef");
|
||||
});
|
||||
|
||||
it("redacts short tokens fully", () => {
|
||||
const input = "TOKEN=shortvalue";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe("TOKEN=***");
|
||||
});
|
||||
|
||||
it("redacts private key blocks", () => {
|
||||
const input = [
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"ABCDEF1234567890",
|
||||
"ZYXWVUT987654321",
|
||||
"-----END PRIVATE KEY-----",
|
||||
].join("\n");
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe(
|
||||
[
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"…redacted…",
|
||||
"-----END PRIVATE KEY-----",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors custom patterns with flags", () => {
|
||||
const input = "token=abcdef1234567890ghij";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "tools",
|
||||
patterns: ["/token=([A-Za-z0-9]+)/i"],
|
||||
});
|
||||
expect(output).toBe("token=abcdef…ghij");
|
||||
});
|
||||
|
||||
it("skips redaction when mode is off", () => {
|
||||
const input = "OPENAI_API_KEY=sk-1234567890abcdef";
|
||||
const output = redactSensitiveText(input, {
|
||||
mode: "off",
|
||||
patterns: defaults,
|
||||
});
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
});
|
||||
125
src/logging/redact.ts
Normal file
125
src/logging/redact.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
|
||||
export type RedactSensitiveMode = "off" | "tools";
|
||||
|
||||
const DEFAULT_REDACT_MODE: RedactSensitiveMode = "tools";
|
||||
const DEFAULT_REDACT_MIN_LENGTH = 18;
|
||||
const DEFAULT_REDACT_KEEP_START = 6;
|
||||
const DEFAULT_REDACT_KEEP_END = 4;
|
||||
|
||||
const DEFAULT_REDACT_PATTERNS: string[] = [
|
||||
// ENV-style assignments.
|
||||
String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`,
|
||||
// JSON fields.
|
||||
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`,
|
||||
// CLI flags.
|
||||
String.raw`--(?:api[-_]?key|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`,
|
||||
// Authorization headers.
|
||||
String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`,
|
||||
String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`,
|
||||
// PEM blocks.
|
||||
String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`,
|
||||
// Common token prefixes.
|
||||
String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`,
|
||||
String.raw`\b(ghp_[A-Za-z0-9]{20,})\b`,
|
||||
String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`,
|
||||
String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`,
|
||||
String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`,
|
||||
String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`,
|
||||
String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`,
|
||||
String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`,
|
||||
String.raw`\b(npm_[A-Za-z0-9]{10,})\b`,
|
||||
String.raw`\b(\d{6,}:[A-Za-z0-9_-]{20,})\b`,
|
||||
];
|
||||
|
||||
type RedactOptions = {
|
||||
mode?: RedactSensitiveMode;
|
||||
patterns?: string[];
|
||||
};
|
||||
|
||||
function normalizeMode(value?: string): RedactSensitiveMode {
|
||||
return value === "off" ? "off" : DEFAULT_REDACT_MODE;
|
||||
}
|
||||
|
||||
function parsePattern(raw: string): RegExp | null {
|
||||
if (!raw.trim()) return null;
|
||||
const match = raw.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||
try {
|
||||
if (match) {
|
||||
const flags = match[2].includes("g") ? match[2] : `${match[2]}g`;
|
||||
return new RegExp(match[1], flags);
|
||||
}
|
||||
return new RegExp(raw, "gi");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePatterns(value?: string[]): RegExp[] {
|
||||
const source = value?.length ? value : DEFAULT_REDACT_PATTERNS;
|
||||
return source.map(parsePattern).filter((re): re is RegExp => Boolean(re));
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length < DEFAULT_REDACT_MIN_LENGTH) return "***";
|
||||
const start = token.slice(0, DEFAULT_REDACT_KEEP_START);
|
||||
const end = token.slice(-DEFAULT_REDACT_KEEP_END);
|
||||
return `${start}…${end}`;
|
||||
}
|
||||
|
||||
function redactPemBlock(block: string): string {
|
||||
const lines = block.split(/\r?\n/).filter(Boolean);
|
||||
if (lines.length < 2) return "***";
|
||||
return `${lines[0]}\n…redacted…\n${lines[lines.length - 1]}`;
|
||||
}
|
||||
|
||||
function redactMatch(match: string, groups: string[]): string {
|
||||
if (match.includes("PRIVATE KEY-----")) return redactPemBlock(match);
|
||||
const token =
|
||||
groups
|
||||
.filter((value) => typeof value === "string" && value.length > 0)
|
||||
.at(-1) ?? match;
|
||||
const masked = maskToken(token);
|
||||
if (token === match) return masked;
|
||||
return match.replace(token, masked);
|
||||
}
|
||||
|
||||
function redactText(text: string, patterns: RegExp[]): string {
|
||||
let next = text;
|
||||
for (const pattern of patterns) {
|
||||
next = next.replace(pattern, (...args: string[]) =>
|
||||
redactMatch(args[0], args.slice(1, args.length - 2)),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveConfigRedaction(): RedactOptions {
|
||||
const cfg = loadConfig().logging;
|
||||
return {
|
||||
mode: normalizeMode(cfg?.redactSensitive),
|
||||
patterns: cfg?.redactPatterns,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactSensitiveText(
|
||||
text: string,
|
||||
options?: RedactOptions,
|
||||
): string {
|
||||
if (!text) return text;
|
||||
const resolved = options ?? resolveConfigRedaction();
|
||||
if (normalizeMode(resolved.mode) === "off") return text;
|
||||
const patterns = resolvePatterns(resolved.patterns);
|
||||
if (!patterns.length) return text;
|
||||
return redactText(text, patterns);
|
||||
}
|
||||
|
||||
export function redactToolDetail(detail: string): string {
|
||||
const resolved = resolveConfigRedaction();
|
||||
if (normalizeMode(resolved.mode) !== "tools") return detail;
|
||||
return redactSensitiveText(detail, resolved);
|
||||
}
|
||||
|
||||
export function getDefaultRedactPatterns(): string[] {
|
||||
return [...DEFAULT_REDACT_PATTERNS];
|
||||
}
|
||||
@ -122,4 +122,38 @@ describe("monitorSlackProvider tool results", () => {
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("threads replies when incoming message is in a thread", async () => {
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorSlackProvider({
|
||||
botToken: "bot-token",
|
||||
appToken: "app-token",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
await handler({
|
||||
event: {
|
||||
type: "message",
|
||||
user: "U1",
|
||||
text: "hello",
|
||||
ts: "123",
|
||||
thread_ts: "456",
|
||||
channel: "C1",
|
||||
channel_type: "im",
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -700,6 +700,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Only thread replies if the incoming message was in a thread.
|
||||
const incomingThreadTs = message.thread_ts;
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
@ -709,6 +712,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
token: botToken,
|
||||
runtime,
|
||||
textLimit,
|
||||
threadTs: incomingThreadTs,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
@ -1379,6 +1383,7 @@ async function deliverReplies(params: {
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
threadTs?: string;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
for (const payload of params.replies) {
|
||||
@ -1389,12 +1394,11 @@ async function deliverReplies(params: {
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, chunkLimit)) {
|
||||
const threadTs = undefined;
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs,
|
||||
threadTs: params.threadTs,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@ -1402,11 +1406,10 @@ async function deliverReplies(params: {
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
const threadTs = undefined;
|
||||
await sendMessageSlack(params.target, caption, {
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
threadTs: params.threadTs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +101,7 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T01:00\+01:00\{Europe\/Vienna\}\]/,
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/,
|
||||
);
|
||||
expect(payload.Body).toContain("hello world");
|
||||
} finally {
|
||||
|
||||
@ -465,9 +465,6 @@ describe("web auto-reply", () => {
|
||||
};
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
messages: {
|
||||
timestampPrefix: "UTC",
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
}));
|
||||
|
||||
@ -500,11 +497,11 @@ describe("web auto-reply", () => {
|
||||
const firstArgs = resolver.mock.calls[0][0];
|
||||
const secondArgs = resolver.mock.calls[1][0];
|
||||
expect(firstArgs.Body).toContain(
|
||||
"[WhatsApp +1 2025-01-01T01:00+01:00{Europe/Vienna}] [clawdbot] first",
|
||||
"[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first",
|
||||
);
|
||||
expect(firstArgs.Body).not.toContain("second");
|
||||
expect(secondArgs.Body).toContain(
|
||||
"[WhatsApp +1 2025-01-01T02:00+01:00{Europe/Vienna}] [clawdbot] second",
|
||||
"[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second",
|
||||
);
|
||||
expect(secondArgs.Body).not.toContain("first");
|
||||
|
||||
@ -1350,7 +1347,6 @@ describe("web auto-reply", () => {
|
||||
messages: {
|
||||
messagePrefix: "[same-phone]",
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1475,7 +1471,6 @@ describe("web auto-reply", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1520,7 +1515,6 @@ describe("web auto-reply", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1565,7 +1559,6 @@ describe("web auto-reply", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1611,7 +1604,6 @@ describe("web auto-reply", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
parseActivationCommand,
|
||||
} from "../auto-reply/group-activation.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
HEARTBEAT_PROMPT,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
@ -369,9 +370,13 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const ackMaxChars = Math.max(
|
||||
0,
|
||||
cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
const stripped = stripHeartbeatToken(replyPayload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: 30,
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||
|
||||
@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@ -144,10 +144,14 @@ export async function monitorWebInbox(options: {
|
||||
continue;
|
||||
const group = isJidGroup(remoteJid);
|
||||
const participantJid = msg.key?.participant ?? undefined;
|
||||
const senderE164 = participantJid ? jidToE164(participantJid) : null;
|
||||
const from = group ? remoteJid : jidToE164(remoteJid);
|
||||
// Skip if we still can't resolve an id to key conversation
|
||||
if (!from) continue;
|
||||
const senderE164 = group
|
||||
? participantJid
|
||||
? jidToE164(participantJid)
|
||||
: null
|
||||
: from;
|
||||
let groupSubject: string | undefined;
|
||||
let groupParticipants: string[] | undefined;
|
||||
if (group) {
|
||||
|
||||
@ -16,7 +16,6 @@ const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -480,7 +479,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -536,7 +534,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -576,7 +573,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -592,7 +588,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -628,7 +623,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -643,7 +637,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -685,7 +678,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -709,7 +701,11 @@ describe("web monitor inbox", () => {
|
||||
|
||||
// Should call onMessage for authorized senders
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "authorized message", from: "+999" }),
|
||||
expect.objectContaining({
|
||||
body: "authorized message",
|
||||
from: "+999",
|
||||
senderE164: "+999",
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset mock for other tests
|
||||
@ -720,7 +716,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -737,7 +732,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -773,7 +767,6 @@ describe("web monitor inbox", () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -840,7 +833,6 @@ it("defaults to self-only when no config is present", async () => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ const DEFAULT_CONFIG = {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -489,6 +489,17 @@ export async function runOnboardingWizard(
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
await ensureSystemdUserLingerInteractive({
|
||||
runtime,
|
||||
prompter: {
|
||||
confirm: prompter.confirm,
|
||||
note: prompter.note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
requireConfirm: false,
|
||||
});
|
||||
|
||||
const installDaemon = await prompter.confirm({
|
||||
message: "Install Gateway daemon (recommended)",
|
||||
initialValue: true,
|
||||
@ -538,17 +549,6 @@ export async function runOnboardingWizard(
|
||||
environment,
|
||||
});
|
||||
}
|
||||
|
||||
await ensureSystemdUserLingerInteractive({
|
||||
runtime,
|
||||
prompter: {
|
||||
confirm: prompter.confirm,
|
||||
note: prompter.note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
requireConfirm: true,
|
||||
});
|
||||
}
|
||||
|
||||
await sleep(1500);
|
||||
|
||||
139
ui/src/ui/controllers/config.test.ts
Normal file
139
ui/src/ui/controllers/config.test.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyConfigSnapshot, type ConfigState } from "./config";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
defaultSlackActions,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type SlackForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const baseTelegramForm: TelegramForm = {
|
||||
token: "",
|
||||
requireMention: true,
|
||||
allowFrom: "",
|
||||
proxy: "",
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
webhookPath: "",
|
||||
};
|
||||
|
||||
const baseDiscordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
replyToMode: "off",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
};
|
||||
|
||||
const baseSlackForm: SlackForm = {
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
textChunkLimit: "",
|
||||
reactionNotifications: "own",
|
||||
reactionAllowlist: "",
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
slashEphemeral: true,
|
||||
actions: { ...defaultSlackActions },
|
||||
channels: [],
|
||||
};
|
||||
|
||||
const baseSignalForm: SignalForm = {
|
||||
enabled: true,
|
||||
account: "",
|
||||
httpUrl: "",
|
||||
httpHost: "",
|
||||
httpPort: "",
|
||||
cliPath: "",
|
||||
autoStart: true,
|
||||
receiveMode: "",
|
||||
ignoreAttachments: false,
|
||||
ignoreStories: false,
|
||||
sendReadReceipts: false,
|
||||
allowFrom: "",
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
const baseIMessageForm: IMessageForm = {
|
||||
enabled: true,
|
||||
cliPath: "",
|
||||
dbPath: "",
|
||||
service: "auto",
|
||||
region: "",
|
||||
allowFrom: "",
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: "",
|
||||
};
|
||||
|
||||
function createState(): ConfigState {
|
||||
return {
|
||||
client: null,
|
||||
connected: false,
|
||||
configLoading: false,
|
||||
configRaw: "",
|
||||
configValid: null,
|
||||
configIssues: [],
|
||||
configSaving: false,
|
||||
configSnapshot: null,
|
||||
configSchema: null,
|
||||
configSchemaVersion: null,
|
||||
configSchemaLoading: false,
|
||||
configUiHints: {},
|
||||
configForm: null,
|
||||
configFormDirty: false,
|
||||
configFormMode: "form",
|
||||
lastError: null,
|
||||
telegramForm: { ...baseTelegramForm },
|
||||
discordForm: { ...baseDiscordForm },
|
||||
slackForm: { ...baseSlackForm },
|
||||
signalForm: { ...baseSignalForm },
|
||||
imessageForm: { ...baseIMessageForm },
|
||||
telegramConfigStatus: null,
|
||||
discordConfigStatus: null,
|
||||
slackConfigStatus: null,
|
||||
signalConfigStatus: null,
|
||||
imessageConfigStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("handles missing slack config without throwing", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
telegram: {},
|
||||
discord: {},
|
||||
signal: {},
|
||||
imessage: {},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.slackForm.botToken).toBe("");
|
||||
expect(state.slackForm.actions).toEqual(defaultSlackActions);
|
||||
});
|
||||
});
|
||||
@ -100,6 +100,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
const config = snapshot.config ?? {};
|
||||
const telegram = (config.telegram ?? {}) as Record<string, unknown>;
|
||||
const discord = (config.discord ?? {}) as Record<string, unknown>;
|
||||
const slack = (config.slack ?? {}) as Record<string, unknown>;
|
||||
const signal = (config.signal ?? {}) as Record<string, unknown>;
|
||||
const imessage = (config.imessage ?? {}) as Record<string, unknown>;
|
||||
const toList = (value: unknown) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user