Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-08 20:58:26 -05:00
committed by GitHub
67 changed files with 2629 additions and 608 deletions

View File

@@ -2,12 +2,17 @@
## Unreleased
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Doctor: run legacy state migrations in non-interactive mode without prompts.
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
@@ -33,6 +38,7 @@
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
@@ -43,11 +49,14 @@
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
## 2026.1.8

View File

@@ -465,5 +465,5 @@ Thanks to all clawtributors:
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></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/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></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/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/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a>
<a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
</p>

View File

@@ -0,0 +1,41 @@
---
summary: "Monitor OAuth expiry for model providers"
read_when:
- Setting up auth expiry monitoring or alerts
- Automating Claude Code / Codex OAuth refresh checks
---
# Auth monitoring
Clawdbot exposes OAuth expiry health via `clawdbot models status`. Use that for
automation and alerting; scripts are optional extras for phone workflows.
## Preferred: CLI check (portable)
```bash
clawdbot models status --check
```
Exit codes:
- `0`: OK
- `1`: expired or missing credentials
- `2`: expiring soon (within 24h)
This works in cron/systemd and requires no extra scripts.
## Optional scripts (ops / phone workflows)
These live under `scripts/` and are **optional**. They assume SSH access to the
gateway host and are tuned for systemd + Termux.
- `scripts/claude-auth-status.sh` now uses `clawdbot models status --json` as the
source of truth (falling back to direct file reads if the CLI is unavailable),
so keep `clawdbot` on `PATH` for timers.
- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone).
- `scripts/systemd/clawdbot-auth-monitor.{service,timer}`: systemd user timer.
- `scripts/claude-auth-status.sh`: Claude Code + Clawdbot auth checker (full/json/simple).
- `scripts/mobile-reauth.sh`: guided reauth flow over SSH.
- `scripts/termux-quick-auth.sh`: onetap widget status + open auth URL.
- `scripts/termux-auth-widget.sh`: full guided widget flow.
- `scripts/termux-sync-widget.sh`: sync Claude Code creds → Clawdbot.
If you dont need phone automation or systemd timers, skip these scripts.

View File

@@ -20,6 +20,7 @@ This page describes the current CLI behavior. If commands change, update this do
- ANSI colors and progress indicators only render in TTY sessions.
- OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs.
- `--json` (and `--plain` where supported) disables styling for clean output.
- `--no-color` disables ANSI styling where supported; `NO_COLOR=1` is also respected.
- Long-running commands show a progress indicator (OSC 9;4 when supported).
## Color palette
@@ -165,8 +166,9 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax|skip>`
- `--auth-choice <oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip>`
- `--anthropic-api-key <key>`
- `--gemini-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
- `--gateway-auth <off|token|password>`
@@ -442,10 +444,17 @@ Notes:
### `logs`
Tail Gateway file logs via RPC.
Notes:
- TTY sessions render a colorized, structured view; non-TTY falls back to plain text.
- `--json` emits line-delimited JSON (one log event per line).
Examples:
```bash
clawdbot logs --follow
clawdbot logs --limit 200
clawdbot logs --plain
clawdbot logs --json
clawdbot logs --no-color
```
### `gateway <subcommand>`
@@ -479,6 +488,9 @@ Options:
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
### `models set <model>`
Set `agent.model.primary`.

View File

@@ -12,6 +12,11 @@ file tools and for workspace context. Keep it private and treat it as memory.
This is separate from `~/.clawdbot/`, which stores config, credentials, and
sessions.
**Important:** the workspace is the **default cwd**, not a hard sandbox. Tools
resolve relative paths against the workspace, but absolute paths can still reach
elsewhere on the host unless sandboxing is enabled. If you need isolation, use
[`agent.sandbox`](/gateway/sandboxing) (and/or peragent sandbox config).
## Default location
- Default: `~/clawd`

View File

@@ -33,6 +33,37 @@ Related:
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
## “Model is not allowed” (and why replies stop)
If `agent.models` is set, it becomes the **allowlist** for `/model` and for
session overrides. When a user selects a model that isnt in that allowlist,
Clawdbot returns:
```
Model "provider/model" is not allowed. Use /model to list available models.
```
This happens **before** a normal reply is generated, so the message can feel
like it “didnt respond.” The fix is to either:
- Add the model to `agent.models`, or
- Clear the allowlist (remove `agent.models`), or
- Pick a model from `/model list`.
Example allowlist config:
```json5
{
agent: {
model: { primary: "anthropic/claude-sonnet-4-5" },
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"anthropic/claude-opus-4-5": { alias: "Opus" }
}
}
}
```
## CLI commands
```bash
@@ -71,7 +102,14 @@ Shows configured models by default. Useful flags:
### `models status`
Shows the resolved primary model, fallbacks, image model, and an auth overview
of configured providers. `--plain` prints only the resolved primary model.
of configured providers. It also surfaces OAuth expiry status for profiles found
in the auth store (warns within 24h by default). `--plain` prints only the
resolved primary model.
OAuth status is always shown (and included in `--json` output). If a configured
provider has no credentials, `models status` prints a **Missing auth** section.
JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
## Scanning (OpenRouter free models)

View File

@@ -17,8 +17,16 @@ An **agent** is a fully scoped brain with its own:
- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
- **Session store** (chat history + routing state) under `~/.clawdbot/agents/<agentId>/sessions`.
Skills are per-agent via each workspaces `skills/` folder, with shared skills
available from `~/.clawdbot/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
The Gateway can host **one agent** (default) or **many agents** side-by-side.
**Workspace note:** each agents workspace is the **default cwd**, not a hard
sandbox. Relative paths resolve inside the workspace, but absolute paths can
reach other host locations unless sandboxing is enabled. See
[Sandboxing](/gateway/sandboxing).
## Paths (quick map)
- Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`)

View File

@@ -49,10 +49,13 @@ If you already signed in with the external CLIs *on the gateway host*, Clawdbot
- Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli`
Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens).
On macOS, the first read may trigger a Keychain prompt; run `clawdbot models status`
in a terminal once if the Gateway runs headless and cant access the entry.
How to verify:
```bash
clawdbot models status
clawdbot providers list
```

View File

@@ -97,6 +97,14 @@
"source": "/bun",
"destination": "/install/bun"
},
{
"source": "/auth-monitoring",
"destination": "/automation/auth-monitoring"
},
{
"source": "/scripts",
"destination": "/scripts"
},
{
"source": "/camera",
"destination": "/nodes/camera"
@@ -576,6 +584,7 @@
"gateway/gateway-lock",
"gateway/configuration",
"gateway/configuration-examples",
"gateway/authentication",
"gateway/background-process",
"gateway/health",
"gateway/heartbeat",
@@ -616,6 +625,7 @@
{
"group": "Automation & Hooks",
"pages": [
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",
"automation/cron-jobs",
@@ -689,6 +699,7 @@
{
"group": "Reference & Templates",
"pages": [
"scripts",
"reference/rpc",
"reference/device-models",
"reference/test",

View File

@@ -0,0 +1,82 @@
---
summary: "Model authentication: OAuth, API keys, and Claude Code token reuse"
read_when:
- Debugging model auth or OAuth expiry
- Documenting authentication or credential storage
---
# Authentication
Clawdbot supports OAuth and API keys for model providers. For Anthropic
subscription accounts, the most stable path is to **reuse Claude Code OAuth
credentials**, including the 1year token created by `claude setup-token`.
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
layout.
## Recommended: longlived Claude Code token
Run this on the **gateway host** (the machine running the Gateway):
```bash
claude setup-token
```
This issues a longlived **OAuth token** (not an API key) and stores it for
Claude Code. Then sync and verify:
```bash
clawdbot models status
clawdbot doctor
```
Automation-friendly check (exit `1` when expired/missing, `2` when expiring):
```bash
clawdbot models status --check
```
Optional ops scripts (systemd/Termux) are documented here:
[/automation/auth-monitoring](/automation/auth-monitoring)
`clawdbot models status` loads Claude Code credentials into Clawdbots
`auth-profiles.json` and shows expiry (warns within 24h by default).
`clawdbot doctor` also performs the sync when it runs.
> `claude setup-token` requires an interactive TTY.
## Checking model auth status
```bash
clawdbot models status
clawdbot doctor
```
## How sync works
1. **Claude Code** stores credentials in `~/.claude/.credentials.json` (or
Keychain on macOS).
2. **Clawdbot** syncs those into
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded.
3. OAuth refresh happens automatically on use if a token is expired.
## Troubleshooting
### “No credentials found”
If the Anthropic OAuth profile is missing, run `claude setup-token` on the
**gateway host**, then re-check:
```bash
clawdbot models status
```
### Token expiring/expired
Run `clawdbot models status` to confirm which profile is expiring. If the profile
is `anthropic:claude-cli`, rerun `claude setup-token`.
## Requirements
- Claude Max or Pro subscription (for `claude setup-token`)
- Claude Code CLI installed (`claude` command available)

View File

@@ -61,6 +61,7 @@ cat ~/.clawdbot/clawdbot.json
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry and can refresh expiring tokens.
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
- Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection.
@@ -135,33 +136,40 @@ Doctor checks:
- **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is
group/world readable and offers to tighten to `600`.
### 5) Sandbox image repair
### 5) Model auth health (OAuth expiry)
Doctor inspects OAuth profiles in the auth store, warns when tokens are
expiring/expired, and can refresh them when safe. If the Anthropic Claude Code
profile is stale, it suggests `claude setup-token` on the gateway host.
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
skips refresh attempts.
### 6) Sandbox image repair
When sandboxing is enabled, doctor checks Docker images and offers to build or
switch to legacy names if the current image is missing.
### 6) Gateway service migrations and cleanup hints
### 7) Gateway service migrations and cleanup hints
Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and
offers to remove them and install the Clawdbot service using the current gateway
port. It can also scan for extra gateway-like services and print cleanup hints
to ensure only one gateway runs per machine.
### 7) Security warnings
### 8) Security warnings
Doctor emits warnings when a provider is open to DMs without an allowlist, or
when a policy is configured in a dangerous way.
### 8) systemd linger (Linux)
### 9) systemd linger (Linux)
If running as a systemd user service, doctor ensures lingering is enabled so the
gateway stays alive after logout.
### 9) Skills status
### 10) Skills status
Doctor prints a quick summary of eligible/missing/blocked skills for the current
workspace.
### 10) Gateway health check + restart
### 11) Gateway health check + restart
Doctor runs a health check and offers to restart the gateway when it looks
unhealthy.
### 11) Supervisor config audit + repair
### 12) Supervisor config audit + repair
Doctor checks the installed supervisor config (launchd/systemd/schtasks) for
missing or outdated defaults (e.g., systemd network-online dependencies and
restart delay). When it finds a mismatch, it recommends an update and can
@@ -174,24 +182,24 @@ Notes:
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
- You can always force a full rewrite via `clawdbot daemon install --force`.
### 12) Gateway runtime + port diagnostics
### 13) Gateway runtime + port diagnostics
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
service is installed but not actually running. It also checks for port collisions
on the gateway port (default `18789`) and reports likely causes (gateway already
running, SSH tunnel).
### 13) Gateway runtime best practices
### 14) Gateway runtime best practices
Doctor warns when the gateway service runs on Bun or a version-managed Node path
(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node,
and version-manager paths can break after upgrades because the daemon does not
load your shell init. Doctor offers to migrate to a system Node install when
available (Homebrew/apt/choco).
### 14) Config write + wizard metadata
### 15) Config write + wizard metadata
Doctor persists any config changes and stamps wizard metadata to record the
doctor run.
### 15) Workspace tips (backup + memory system)
### 16) Workspace tips (backup + memory system)
Doctor suggests a workspace memory system when missing and prints a backup tip
if the workspace is not already under git.

View File

@@ -7,6 +7,8 @@ read_when:
# Logging
For a user-facing overview (CLI + Control UI + config), see [/logging](/logging).
Clawdbot has two log “surfaces”:
- **Console output** (what you see in the terminal / Debug UI).

View File

@@ -77,6 +77,13 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Run sensitive tool execution in a sandbox; keep secrets out of the agents reachable filesystem.
- **Model choice matters:** we recommend Anthropic Opus 4.5 because its quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). Using weaker models increases risk.
## Reasoning & verbose output in groups
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
was not meant for a public channel. In group settings, treat them as **debug
only** and keep them off unless you explicitly need them. If you enable them,
do so only in trusted DMs or tightly controlled rooms.
## Lessons Learned (The Hard Way)
### The `find ~` Incident 🦞

View File

@@ -33,6 +33,19 @@ Doctor/daemon will show runtime state (PID/last exit) and log hints.
- Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST`
**Enable more logging:**
- Bump file log detail (persisted JSONL):
```json
{ "logging": { "level": "debug" } }
```
- Bump console verbosity (TTY output only):
```json
{ "logging": { "consoleLevel": "debug", "consoleStyle": "pretty" } }
```
- Quick tip: `--verbose` affects **console** output only. File logs remain controlled by `logging.level`.
See [/logging](/logging) for a full overview of formats, config, and access.
### Service Environment (PATH + runtime)
The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft:

205
docs/install/ansible.md Normal file
View File

@@ -0,0 +1,205 @@
---
summary: "Automated, hardened Clawdbot installation with Ansible, Tailscale VPN, and firewall isolation"
read_when:
- You want automated server deployment with security hardening
- You need firewall-isolated setup with VPN access
- You're deploying to remote Debian/Ubuntu servers
---
# Ansible Installation
The recommended way to deploy Clawdbot to production servers is via **[clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible)** — an automated installer with security-first architecture.
## Quick Start
One-command install:
```bash
curl -fsSL https://raw.githubusercontent.com/clawdbot/clawdbot-ansible/main/install.sh | bash
```
> **📦 Full guide: [github.com/clawdbot/clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible)**
>
> The clawdbot-ansible repo is the source of truth for Ansible deployment. This page is a quick overview.
## What You Get
- 🔒 **Firewall-first security**: UFW + Docker isolation (only SSH + Tailscale accessible)
- 🔐 **Tailscale VPN**: Secure remote access without exposing services publicly
- 🐳 **Docker**: Isolated sandbox containers, localhost-only bindings
- 🛡️ **Defense in depth**: 4-layer security architecture
- 🚀 **One-command setup**: Complete deployment in minutes
- 🔧 **Systemd integration**: Auto-start on boot with hardening
## Requirements
- **OS**: Debian 11+ or Ubuntu 20.04+
- **Access**: Root or sudo privileges
- **Network**: Internet connection for package installation
- **Ansible**: 2.14+ (installed automatically by quick-start script)
## What Gets Installed
The Ansible playbook installs and configures:
1. **Tailscale** (mesh VPN for secure remote access)
2. **UFW firewall** (SSH + Tailscale ports only)
3. **Docker CE + Compose V2** (for agent sandboxes)
4. **Node.js 22.x + pnpm** (runtime dependencies)
5. **Clawdbot** (host-based, not containerized)
6. **Systemd service** (auto-start with security hardening)
Note: The gateway runs **directly on the host** (not in Docker), but agent sandboxes use Docker for isolation. See [Sandboxing](/gateway/sandboxing) for details.
## Post-Install Setup
After installation completes, switch to the clawdbot user:
```bash
sudo -i -u clawdbot
```
The post-install script will guide you through:
1. **Onboarding wizard**: Configure Clawdbot settings
2. **Provider login**: Connect WhatsApp/Telegram/Discord/Signal
3. **Gateway testing**: Verify the installation
4. **Tailscale setup**: Connect to your VPN mesh
### Quick commands
```bash
# Check service status
sudo systemctl status clawdbot
# View live logs
sudo journalctl -u clawdbot -f
# Restart gateway
sudo systemctl restart clawdbot
# Provider login (run as clawdbot user)
sudo -i -u clawdbot
clawdbot login
```
## Security Architecture
### 4-Layer Defense
1. **Firewall (UFW)**: Only SSH (22) + Tailscale (41641/udp) exposed publicly
2. **VPN (Tailscale)**: Gateway accessible only via VPN mesh
3. **Docker Isolation**: DOCKER-USER iptables chain prevents external port exposure
4. **Systemd Hardening**: NoNewPrivileges, PrivateTmp, unprivileged user
### Verification
Test external attack surface:
```bash
nmap -p- YOUR_SERVER_IP
```
Should show **only port 22** (SSH) open. All other services (gateway, Docker) are locked down.
### Docker Availability
Docker is installed for **agent sandboxes** (isolated tool execution), not for running the gateway itself. The gateway binds to localhost only and is accessible via Tailscale VPN.
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for sandbox configuration.
## Manual Installation
If you prefer manual control over the automation:
```bash
# 1. Install prerequisites
sudo apt update && sudo apt install -y ansible git
# 2. Clone repository
git clone https://github.com/clawdbot/clawdbot-ansible.git
cd clawdbot-ansible
# 3. Install Ansible collections
ansible-galaxy collection install -r requirements.yml
# 4. Run playbook
./run-playbook.sh
# Or run directly (then manually execute /tmp/clawdbot-setup.sh after)
# ansible-playbook playbook.yml --ask-become-pass
```
## Updating Clawdbot
The Ansible installer sets up Clawdbot for manual updates. See [Updating](/install/updating) for the standard update flow.
To re-run the Ansible playbook (e.g., for configuration changes):
```bash
cd clawdbot-ansible
./run-playbook.sh
```
Note: This is idempotent and safe to run multiple times.
## Troubleshooting
### Firewall blocks my connection
If you're locked out:
- Ensure you can access via Tailscale VPN first
- SSH access (port 22) is always allowed
- The gateway is **only** accessible via Tailscale by design
### Service won't start
```bash
# Check logs
sudo journalctl -u clawdbot -n 100
# Verify permissions
sudo ls -la /opt/clawdbot
# Test manual start
sudo -i -u clawdbot
cd ~/clawdbot
pnpm start
```
### Docker sandbox issues
```bash
# Verify Docker is running
sudo systemctl status docker
# Check sandbox image
sudo docker images | grep clawdbot-sandbox
# Build sandbox image if missing
cd /opt/clawdbot/clawdbot
sudo -u clawdbot ./scripts/sandbox-setup.sh
```
### Provider login fails
Make sure you're running as the `clawdbot` user:
```bash
sudo -i -u clawdbot
clawdbot login
```
## Advanced Configuration
For detailed security architecture and troubleshooting:
- [Security Architecture](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/security.md)
- [Technical Details](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/architecture.md)
- [Troubleshooting Guide](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/troubleshooting.md)
## Related
- [clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible) — full deployment guide
- [Docker](/install/docker) — containerized gateway setup
- [Sandboxing](/gateway/sandboxing) — agent sandbox configuration
- [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) — per-agent isolation

144
docs/logging.md Normal file
View File

@@ -0,0 +1,144 @@
---
summary: "Logging overview: file logs, console output, CLI tailing, and the Control UI"
read_when:
- You need a beginner-friendly overview of logging
- You want to configure log levels or formats
- You are troubleshooting and need to find logs quickly
---
# Logging
Clawdbot logs in two places:
- **File logs** (JSON lines) written by the Gateway.
- **Console output** shown in terminals and the Control UI.
This page explains where logs live, how to read them, and how to configure log
levels and formats.
## Where logs live
By default, the Gateway writes a rolling log file under:
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
You can override this in `~/.clawdbot/clawdbot.json`:
```json
{
"logging": {
"file": "/path/to/clawdbot.log"
}
}
```
## How to read logs
### CLI: live tail (recommended)
Use the CLI to tail the gateway log file via RPC:
```bash
clawdbot logs --follow
```
Output modes:
- **TTY sessions**: pretty, colorized, structured log lines.
- **Non-TTY sessions**: plain text.
- `--json`: line-delimited JSON (one log event per line).
- `--plain`: force plain text in TTY sessions.
- `--no-color`: disable ANSI colors.
In JSON mode, the CLI emits `type`-tagged objects:
- `meta`: stream metadata (file, cursor, size)
- `log`: parsed log entry
- `notice`: truncation / rotation hints
- `raw`: unparsed log line
If the Gateway is unreachable, the CLI prints a short hint to run:
```bash
clawdbot doctor
```
### Control UI (web)
The Control UIs **Logs** tab tails the same file using `logs.tail`.
See [/web/control-ui](/web/control-ui) for how to open it.
### Provider-only logs
To filter provider activity (WhatsApp/Telegram/etc), use:
```bash
clawdbot providers logs --provider whatsapp
```
## Log formats
### File logs (JSONL)
Each line in the log file is a JSON object. The CLI and Control UI parse these
entries to render structured output (time, level, subsystem, message).
### Console output
Console logs are **TTY-aware** and formatted for readability:
- Subsystem prefixes (e.g. `gateway/providers/whatsapp`)
- Level coloring (info/warn/error)
- Optional compact or JSON mode
Console formatting is controlled by `logging.consoleStyle`.
## Configuring logging
All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`.
```json
{
"logging": {
"level": "info",
"file": "/tmp/clawdbot/clawdbot-YYYY-MM-DD.log",
"consoleLevel": "info",
"consoleStyle": "pretty",
"redactSensitive": "tools",
"redactPatterns": [
"sk-.*"
]
}
}
```
### Log levels
- `logging.level`: **file logs** (JSONL) level.
- `logging.consoleLevel`: **console** verbosity level.
`--verbose` only affects console output; it does not change file log levels.
### Console styles
`logging.consoleStyle`:
- `pretty`: human-friendly, colored, with timestamps.
- `compact`: tighter output (best for long sessions).
- `json`: JSON per line (for log processors).
### Redaction
Tool summaries can redact sensitive tokens before they hit the console:
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: list of regex strings to override the default set
Redaction affects **console output only** and does not alter file logs.
## Troubleshooting tips
- **Gateway not reachable?** Run `clawdbot doctor` first.
- **Logs empty?** Check that the Gateway is running and writing to the file path
in `logging.file`.
- **Need more detail?** Set `logging.level` to `debug` or `trace` and retry.

26
docs/scripts.md Normal file
View File

@@ -0,0 +1,26 @@
---
summary: "Repository scripts: purpose, scope, and safety notes"
read_when:
- Running scripts from the repo
- Adding or changing scripts under ./scripts
---
# Scripts
The `scripts/` directory contains helper scripts for local workflows and ops tasks.
Use these when a task is clearly tied to a script; otherwise prefer the CLI.
## Conventions
- Scripts are **optional** unless referenced in docs or release checklists.
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
- Assume scripts are hostspecific; read them before running on a new machine.
## Auth monitoring scripts
Auth monitoring scripts are documented here:
[/automation/auth-monitoring](/automation/auth-monitoring)
## When adding scripts
- Keep scripts focused and documented.
- Add a short entry in the relevant doc (or create one if missing).

View File

@@ -33,10 +33,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Asks the running gateway for a full snapshot (WS-only). See [Health](/gateway/health).
5) **Tail the latest log**
```bash
clawdbot logs --follow
```
If RPC is down, fall back to:
```bash
tail -f "$(ls -t /tmp/clawdbot/clawdbot-*.log | head -1)"
```
File logs are separate from service logs; see [Logging](/gateway/logging) and [Troubleshooting](/gateway/troubleshooting).
File logs are separate from service logs; see [Logging](/logging) and [Troubleshooting](/gateway/troubleshooting).
## What is Clawdbot?
@@ -114,6 +118,26 @@ Legacy singleagent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`
Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agent.workspace` (default: `~/clawd`).
### Can agents work outside the workspace?
Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox.
Relative paths resolve inside the workspace, but absolute paths can access other
host locations unless sandboxing is enabled. If you need isolation, use
[`agent.sandbox`](/gateway/sandboxing) or peragent sandbox settings. If you
want a repo to be the default working directory, point that agents
`workspace` to the repo root. The Clawdbot repo is just source code; keep the
workspace separate unless you intentionally want the agent to work inside it.
Example (repo as default cwd):
```json5
{
agent: {
workspace: "~/Projects/my-repo"
}
}
```
### Im in remote mode — where is the session store?
Session state is owned by the **gateway host**. If youre in remote mode, the session store you care about is on the remote machine, not your local laptop. See [Session management](/concepts/session).
@@ -257,6 +281,18 @@ Use the `/model` command as a standalone message:
You can list available models with `/model`, `/model list`, or `/model status`.
### Why do I see “Model … is not allowed” and then no reply?
If `agent.models` is set, it becomes the **allowlist** for `/model` and any
session overrides. Choosing a model that isnt in that list returns:
```
Model "provider/model" is not allowed. Use /model to list available models.
```
That error is returned **instead of** a normal reply. Fix: add the model to
`agent.models`, remove the allowlist, or pick a model from `/model list`.
### Are opus / sonnet / gpt builtin shortcuts?
Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agent.models`):
@@ -346,7 +382,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
- **Make sure youre editing the correct agent**
- Multiagent setups mean there can be multiple `auth-profiles.json` files.
- **Sanitycheck model/auth status**
- Use `/model status` to see configured models and whether providers are authenticated.
- Use `clawdbot models status` to see configured models and whether providers are authenticated.
### Why did it also try Google Gemini and fail?

View File

@@ -170,6 +170,17 @@ clawdbot onboard --non-interactive \
Add `--json` for a machinereadable summary.
Gemini example:
```bash
clawdbot onboard --non-interactive \
--mode local \
--auth-choice gemini-api-key \
--gemini-api-key "$GEMINI_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Add agent (noninteractive) example:
```bash

View File

@@ -50,7 +50,8 @@ bun add -g clawdhub
By default, the CLI installs skills into `./skills` under your current working directory. Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
For more detail on how skills are loaded and gated, see `docs/skills.md`.
For more detail on how skills are loaded, shared, and gated, see
[Skills](/tools/skills).
## What the service provides (features)

View File

@@ -15,7 +15,7 @@ read_when:
- **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere.
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated runs `bash` on the host (bypasses sandbox).
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `bash` runs.
- **Tool policy still applies**: if `bash` is denied by tool policy, elevated cannot be used.

View File

@@ -23,6 +23,36 @@ If a skill name conflicts, precedence is:
Additionally, you can configure extra skill folders (lowest precedence) via
`skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`.
## Per-agent vs shared skills
In **multi-agent** setups, each agent has its own workspace. That means:
- **Per-agent skills** live in `<workspace>/skills` for that agent only.
- **Shared skills** live in `~/.clawdbot/skills` (managed/local) and are visible
to **all agents** on the same machine.
- **Shared folders** can also be added via `skills.load.extraDirs` (lowest
precedence) if you want a common skills pack used by multiple agents.
If the same skill name exists in more than one place, the usual precedence
applies: workspace wins, then managed/local, then bundled.
## ClawdHub (install + sync)
ClawdHub is the public skills registry for Clawdbot. Use it to discover,
install, update, and back up skills. Full guide: [ClawdHub](/tools/clawdhub).
Common flows:
- Install a skill into your workspace:
- `clawdhub install <skill-slug>`
- Update all installed skills:
- `clawdhub update --all`
- Sync (scan + publish updates):
- `clawdhub sync --all`
By default, `clawdhub` installs into `./skills` under your current working
directory; Clawdbot picks that up as `<workspace>/skills` on the next session.
## Format (AgentSkills + Pi-compatible)
`SKILL.md` must include at least:

View File

@@ -53,6 +53,8 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
## Surface notes

View File

@@ -97,10 +97,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.38.0",
"@mariozechner/pi-ai": "^0.38.0",
"@mariozechner/pi-coding-agent": "^0.38.0",
"@mariozechner/pi-tui": "^0.38.0",
"@mariozechner/pi-agent-core": "^0.40.0",
"@mariozechner/pi-ai": "^0.40.0",
"@mariozechner/pi-coding-agent": "^0.40.0",
"@mariozechner/pi-tui": "^0.40.0",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
@@ -165,7 +165,6 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch"
}
},
@@ -202,7 +201,6 @@
]
},
"patchedDependencies": {
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"qrcode-terminal": "patches/qrcode-terminal.patch",

View File

@@ -1,434 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index b1d6a340e1817b6f5404c2a23efa49139249f754..6606b09bd4eeee475899a840e6f6fa62b77b6a05 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -7,6 +7,94 @@ import { calculateCost } from "../models.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { convertMessages, convertTools, isThinkingPart, mapStopReasonString, mapToolChoice, retainThoughtSignature, } from "./google-shared.js";
+// ============================================================================
+// ANTIGRAVITY SYSTEM INSTRUCTION (Ported from CLIProxyAPI v6.6.89)
+// ============================================================================
+/**
+ * System instruction for Antigravity requests.
+ * This is injected into requests to match CLIProxyAPI v6.6.89 behavior.
+ * The instruction provides identity and guidelines for the Antigravity agent.
+ */
+const ANTIGRAVITY_SYSTEM_INSTRUCTION = `<identity>
+You are Antigravity, a powerful agentic AI coding assistant designed by the Google DeepMind team working on Advanced Agentic Coding.
+You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
+The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is.
+This information may or may not be relevant to the coding task, it is up for you to decide.
+</identity>
+
+<tool_calling>
+Call tools as you normally would. The following list provides additional guidance to help you avoid errors:
+ - **Absolute paths only**. When using tools that accept file path arguments, ALWAYS use the absolute file path.
+</tool_calling>
+
+<web_application_development>
+## Technology Stack
+Your web applications should be built using the following technologies:
+1. **Core**: Use HTML for structure and JavaScript for logic.
+2. **Styling (CSS)**: Use Vanilla CSS for maximum flexibility and control. Avoid using TailwindCSS unless the USER explicitly requests it; in this case, first confirm which TailwindCSS version to use.
+3. **Web App**: If the USER specifies that they want a more complex web app, use a framework like Next.js or Vite. Only do this if the USER explicitly requests a web app.
+4. **New Project Creation**: If you need to use a framework for a new app, use \`npx\` with the appropriate script, but there are some rules to follow:
+ - Use \`npx -y\` to automatically install the script and its dependencies
+ - You MUST run the command with \`--help\` flag to see all available options first
+ - Initialize the app in the current directory with \`./\` (example: \`npx -y create-vite-app@latest ./\`)
+ - You should run in non-interactive mode so that the user doesn't need to input anything
+5. **Running Locally**: When running locally, use \`npm run dev\` or equivalent dev server. Only build the production bundle if the USER explicitly requests it or you are validating the code for correctness.
+
+# Design Aesthetics
+1. **Use Rich Aesthetics**: The USER should be wowed at first glance by the design. Use best practices in modern web design (e.g. vibrant colors, dark modes, glassmorphism, and dynamic animations) to create a stunning first impression. Failure to do this is UNACCEPTABLE.
+2. **Prioritize Visual Excellence**: Implement designs that will WOW the user and feel extremely premium:
+ - Avoid generic colors (plain red, blue, green). Use curated, harmonious color palettes (e.g., HSL tailored colors, sleek dark modes).
+ - Using modern typography (e.g., from Google Fonts like Inter, Roboto, or Outfit) instead of browser defaults.
+ - Use smooth gradients
+ - Add subtle micro-animations for enhanced user experience
+3. **Use a Dynamic Design**: An interface that feels responsive and alive encourages interaction. Achieve this with hover effects and interactive elements. Micro-animations, in particular, are highly effective for improving user engagement.
+4. **Premium Designs**: Make a design that feels premium and state of the art. Avoid creating simple minimum viable products.
+5. **Don't use placeholders**: If you need an image, use your generate_image tool to create a working demonstration.
+
+## Implementation Workflow
+Follow this systematic approach when building web applications:
+1. **Plan and Understand**:
+ - Fully understand the user's requirements
+ - Draw inspiration from modern, beautiful, and dynamic web designs
+ - Outline the features needed for the initial version
+2. **Build the Foundation**:
+ - Start by creating/modifying \`index.css\`
+ - Implement the core design system with all tokens and utilities
+3. **Create Components**:
+ - Build necessary components using your design system
+ - Ensure all components use predefined styles, not ad-hoc utilities
+ - Keep components focused and reusable
+4. **Assemble Pages**:
+ - Update the main application to incorporate your design and components
+ - Ensure proper routing and navigation
+ - Implement responsive layouts
+5. **Polish and Optimize**:
+ - Review the overall user experience
+ - Ensure smooth interactions and transitions
+ - Optimize performance where needed
+
+## SEO Best Practices
+Automatically implement SEO best practices on every page:
+- **Title Tags**: Include proper, descriptive title tags for each page
+- **Meta Descriptions**: Add compelling meta descriptions that accurately summarize page content
+- **Heading Structure**: Use a single \`<h1>\` per page with proper heading hierarchy
+- **Semantic HTML**: Use appropriate HTML5 semantic elements
+- **Unique IDs**: Ensure all interactive elements have unique, descriptive IDs for browser testing
+- **Performance**: Ensure fast page load times through optimization
+CRITICAL REMINDER: AESTHETICS ARE VERY IMPORTANT. If your web app looks simple and basic then you have FAILED!
+</web_application_development>
+<ephemeral_message>
+There will be an <EPHEMERAL_MESSAGE> appearing in the conversation at times. This is not coming from the user, but instead injected by the system as important information to pay attention to.
+Do not respond to nor acknowledge those messages, but do follow them strictly.
+</ephemeral_message>
+
+
+<communication_style>
+- **Formatting**. Format your responses in github-style markdown to make your responses easier for the USER to parse. For example, use headers to organize your responses and bolded or italicized text to highlight important keywords. Use backticks to format file, directory, function, and class names. If providing a URL to the user, format this in markdown as well, for example \`[label](example.com)\`.
+- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.
+- **Helpfulness**. Respond like a helpful software engineer who is explaining your work to a friendly collaborator on the project. Acknowledge mistakes or any backtracking you do as a result of new information.
+- **Ask for clarification**. If you are unsure about the USER's intent, always ask for clarification rather than making assumptions.
+</communication_style>`;
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
// Headers for Gemini CLI (prod endpoint)
const GEMINI_CLI_HEADERS = {
@@ -139,11 +227,12 @@ export const streamGoogleGeminiCli = (model, context, options) => {
if (!accessToken || !projectId) {
throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate.");
}
- const requestBody = buildRequest(model, context, projectId, options);
const endpoint = model.baseUrl || DEFAULT_ENDPOINT;
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
// Use Antigravity headers for sandbox endpoint, otherwise Gemini CLI headers
const isAntigravity = endpoint.includes("sandbox.googleapis.com");
+ // PATCH: Pass isAntigravity to buildRequest for system instruction injection (CLIProxyAPI v6.6.89 compat)
+ const requestBody = buildRequest(model, context, projectId, options, isAntigravity);
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
// Fetch with retry logic for rate limits and transient errors
let response;
@@ -168,7 +257,12 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
- // Check if retryable
+ // PATCH: Fail immediately on 429 to let caller rotate accounts
+ if (response.status === 429) {
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
+ // Check if retryable (non-429 errors)
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
const serverDelay = extractRetryDelay(errorText);
@@ -183,6 +277,10 @@ export const streamGoogleGeminiCli = (model, context, options) => {
if (error instanceof Error && error.message === "Request was aborted") {
throw error;
}
+ // PATCH: Don't retry 429 errors - let caller rotate accounts
+ if (error instanceof Error && error.message.includes("429")) {
+ throw error;
+ }
lastError = error instanceof Error ? error : new Error(String(error));
// Network errors are retryable
if (attempt < MAX_RETRIES) {
@@ -402,7 +500,7 @@ export const streamGoogleGeminiCli = (model, context, options) => {
})();
return stream;
};
-function buildRequest(model, context, projectId, options = {}) {
+function buildRequest(model, context, projectId, options = {}, isAntigravity = false) {
const contents = convertMessages(model, context);
const generationConfig = {};
if (options.temperature !== undefined) {
@@ -447,12 +545,23 @@ function buildRequest(model, context, projectId, options = {}) {
};
}
}
- return {
+ // PATCH: Inject Antigravity system instruction with role "user" (CLIProxyAPI v6.6.89 compatibility)
+ if (isAntigravity) {
+ const existingText = request.systemInstruction?.parts?.[0]?.text || "";
+ request.systemInstruction = {
+ role: "user",
+ parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION + (existingText ? "\n\n" + existingText : "") }],
+ };
+ }
+ // PATCH: Build wrapped body with requestType for Antigravity (CLIProxyAPI v6.6.89 compatibility)
+ const wrappedBody = {
project: projectId,
model: model.id,
request,
- userAgent: "pi-coding-agent",
- requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
+ ...(isAntigravity && { requestType: "agent" }),
+ userAgent: isAntigravity ? "antigravity" : "pi-coding-agent",
+ requestId: `${isAntigravity ? "agent" : "pi"}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
};
+ return wrappedBody;
}
//# sourceMappingURL=google-gemini-cli.js.map
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index dbb9c0e263919c9184a5f1c7dfde47d1c3a37ff4..f1866f423f30a4dfbe812d052679abd1f011769f 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -41,13 +41,27 @@ export function retainThoughtSignature(existing, incoming) {
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+ /**
+ * Helper to add content while merging consecutive messages of the same role.
+ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model).
+ * Consecutive messages of the same role cause "function call turn" errors.
+ */
+ function addContent(role, parts) {
+ if (parts.length === 0)
+ return;
+ const lastContent = contents[contents.length - 1];
+ if (lastContent?.role === role) {
+ // Merge into existing message of same role
+ lastContent.parts.push(...parts);
+ }
+ else {
+ contents.push({ role, parts });
+ }
+ }
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
- contents.push({
- role: "user",
- parts: [{ text: sanitizeSurrogates(msg.content) }],
- });
+ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]);
}
else {
const parts = msg.content.map((item) => {
@@ -66,10 +80,7 @@ export function convertMessages(model, context) {
const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts;
if (filteredParts.length === 0)
continue;
- contents.push({
- role: "user",
- parts: filteredParts,
- });
+ addContent("user", filteredParts);
}
}
else if (msg.role === "assistant") {
@@ -82,9 +93,19 @@ export function convertMessages(model, context) {
parts.push({ text: sanitizeSurrogates(block.text) });
}
else if (block.type === "thinking") {
- // Thinking blocks require signatures for Claude via Antigravity.
- // If signature is missing (e.g. from GPT-OSS), convert to regular text with delimiters.
- if (block.thinkingSignature) {
+ // Thinking blocks handling varies by model:
+ // - Claude via Antigravity: requires thinkingSignature
+ // - Gemini: skip entirely (doesn't understand thoughtSignature, and mimics <thinking> tags)
+ // - Other models: convert to text with delimiters
+ const isGemini = model.id.toLowerCase().includes("gemini");
+ const isClaude = model.id.toLowerCase().includes("claude");
+ if (isGemini) {
+ // Skip thinking blocks entirely for Gemini - it doesn't support them
+ // and will mimic <thinking> tags if we convert to text
+ continue;
+ }
+ if (block.thinkingSignature && isClaude) {
+ // Claude via Antigravity requires the signature
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
@@ -92,6 +113,7 @@ export function convertMessages(model, context) {
});
}
else {
+ // Other models: convert to text with delimiters
parts.push({
text: `<thinking>\n${sanitizeSurrogates(block.thinking)}\n</thinking>`,
});
@@ -116,10 +138,7 @@ export function convertMessages(model, context) {
}
if (parts.length === 0)
continue;
- contents.push({
- role: "model",
- parts,
- });
+ addContent("model", parts);
}
else if (msg.role === "toolResult") {
// Extract text and image content
@@ -156,27 +175,97 @@ export function convertMessages(model, context) {
}
// Cloud Code Assist API requires all function responses to be in a single user turn.
// Check if the last content is already a user turn with function responses and merge.
+ // Use addContent for proper role alternation handling.
const lastContent = contents[contents.length - 1];
if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
lastContent.parts.push(functionResponsePart);
}
else {
- contents.push({
- role: "user",
- parts: [functionResponsePart],
- });
+ addContent("user", [functionResponsePart]);
}
// For older models, add images in a separate user message
+ // Note: This may create consecutive user messages, but addContent will merge them
if (hasImages && !supportsMultimodalFunctionResponse) {
- contents.push({
- role: "user",
- parts: [{ text: "Tool result image:" }, ...imageParts],
- });
+ addContent("user", [{ text: "Tool result image:" }, ...imageParts]);
}
}
}
return contents;
}
+/**
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
+ * and converts to a format compatible with Google's function declarations.
+ */
+function sanitizeSchemaForGoogle(schema) {
+ if (!schema || typeof schema !== "object") {
+ return schema;
+ }
+ // If it's an array, sanitize each element
+ if (Array.isArray(schema)) {
+ return schema.map((item) => sanitizeSchemaForGoogle(item));
+ }
+ const sanitized = {};
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
+ const unsupportedKeywords = [
+ "patternProperties",
+ "const",
+ "anyOf",
+ "oneOf",
+ "allOf",
+ "not",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "if",
+ "then",
+ "else",
+ "dependentSchemas",
+ "dependentRequired",
+ "unevaluatedProperties",
+ "unevaluatedItems",
+ "contentEncoding",
+ "contentMediaType",
+ "contentSchema",
+ "deprecated",
+ "readOnly",
+ "writeOnly",
+ "examples",
+ "$comment",
+ "additionalProperties",
+ ];
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
+ for (const [key, value] of Object.entries(schema)) {
+ // Skip unsupported keywords
+ if (unsupportedKeywords.includes(key)) {
+ continue;
+ }
+ // Recursively sanitize nested objects
+ if (key === "properties" && typeof value === "object" && value !== null) {
+ sanitized[key] = {};
+ for (const [propKey, propValue] of Object.entries(value)) {
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
+ }
+ }
+ else if (key === "items" && typeof value === "object") {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ }
+ else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ sanitized[key] = sanitizeSchemaForGoogle(value);
+ }
+ else {
+ sanitized[key] = value;
+ }
+ }
+ // Ensure type: "object" is present when properties or required exist
+ // Google API requires type to be set when these fields are present
+ if (("properties" in sanitized || "required" in sanitized) && !("type" in sanitized)) {
+ sanitized.type = "object";
+ }
+ return sanitized;
+}
/**
* Convert tools to Gemini function declarations format.
*/
@@ -188,7 +277,7 @@ export function convertTools(tools) {
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
- parameters: tool.parameters,
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
})),
},
];
diff --git a/dist/providers/openai-completions.d.ts b/dist/providers/openai-completions.d.ts
index 723addf341696b5d69c079202e571e9917685ce4..a1d0584a70a7d1fad1332026e301e56ef4f700a8 100644
--- a/dist/providers/openai-completions.d.ts
+++ b/dist/providers/openai-completions.d.ts
@@ -7,6 +7,8 @@ export interface OpenAICompletionsOptions extends StreamOptions {
};
};
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
+ /** Extra params to pass directly to the API (e.g., Z.AI GLM thinking mode params) */
+ extraParams?: Record<string, unknown>;
}
export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
//# sourceMappingURL=openai-completions.d.ts.map
diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js
index 2590381cc5544c4e73c24c1c9a5853202f31361b..b76e1087dd31ccf099e02b1214b9e12d371b9b2d 100644
--- a/dist/providers/openai-completions.js
+++ b/dist/providers/openai-completions.js
@@ -335,6 +335,11 @@ function buildParams(model, context, options) {
if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
params.reasoning_effort = options.reasoningEffort;
}
+ // PATCH: Support arbitrary extra params for provider-specific features
+ // (e.g., Z.AI GLM-4.7 thinking: { type: "enabled", clear_thinking: boolean })
+ if (options?.extraParams && typeof options.extraParams === "object") {
+ Object.assign(params, options.extraParams);
+ }
return params;
}
function convertMessages(model, context, compat) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -486,7 +486,6 @@ function convertTools(tools) {
name: tool.name,
description: tool.description,
parameters: tool.parameters, // TypeBox already generates JSON Schema
- strict: null,
}));
}
function mapStopReason(status) {
diff --git a/dist/stream.js b/dist/stream.js
index da54f4270e9b8d9e9cf1f902af976cc239601d4c..7ed71597c3369f8e3c1a3da0eb870a68215b714d 100644
--- a/dist/stream.js
+++ b/dist/stream.js
@@ -108,6 +108,8 @@ function mapOptionsForApi(model, options, apiKey) {
signal: options?.signal,
apiKey: apiKey || options?.apiKey,
sessionId: options?.sessionId,
+ // PATCH: Pass extraParams through to provider-specific API handlers
+ extraParams: options?.extraParams,
};
// Helper to clamp xhigh to high for providers that don't support it
const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort);

73
pnpm-lock.yaml generated
View File

@@ -11,9 +11,6 @@ patchedDependencies:
'@mariozechner/pi-agent-core':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch
'@mariozechner/pi-ai':
hash: 574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a
path: patches/@mariozechner__pi-ai.patch
importers:
@@ -35,17 +32,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.38.0
version: 0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.40.0
version: 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.38.0
version: 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5)
specifier: ^0.40.0
version: 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.38.0
version: 0.38.0(ws@8.19.0)(zod@4.3.5)
specifier: ^0.40.0
version: 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.38.0
version: 0.38.0
specifier: ^0.40.0
version: 0.40.0
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
@@ -744,8 +741,8 @@ packages:
'@lit-labs/signals@0.2.0':
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
'@lit-labs/ssr-dom-shim@1.5.0':
resolution: {integrity: sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA==}
'@lit-labs/ssr-dom-shim@1.5.1':
resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==}
'@lit/context@1.1.6':
resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==}
@@ -815,22 +812,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.38.0':
resolution: {integrity: sha512-VtX2j0cSefdZ6X+osUZXLp8BRT2ZB6utxl7IWoebRq0iPpJScUGUNB+K0POUduW90MmraNUvFCrKhEZSWffs+g==}
'@mariozechner/pi-agent-core@0.40.0':
resolution: {integrity: sha512-l43rJlKJVTaKPIIMTKe6AHYLSN/6FU/zZ//uUK6BCp4CNJlcAN2iX4wdXC9t+QoAnpshJFheBP6kXS2ynFhxuw==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.38.0':
resolution: {integrity: sha512-AOH5LIsC6EgaTiYe0er9trZhuba/lk62xDlTxVNxskrF+wiNhuBWue7MQ9BQIyzWDh8sEVvNhnbXIKBX7LYdbw==}
'@mariozechner/pi-ai@0.40.0':
resolution: {integrity: sha512-OiE6ir7bVEFVnXY/Jd4uIDMTOTdXpDlMpmJ8qXhlp5SlVzjiZkuPEJS3Hki8j4DnwdkPGMWyOX4kZi8FCrtBUA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.38.0':
resolution: {integrity: sha512-fBCgOUSrca/CpU+LPeEl0PJnOPAHlovbsEf3XbQ+MctreC5zMCvD61mdfdeHnuvu/jBer+WVjnGyNy0j0f0Z0Q==}
'@mariozechner/pi-coding-agent@0.40.0':
resolution: {integrity: sha512-IUTZxZkNjnzoZmpjPODmAkM9K2Eoq8LBDqYB1LZwr/f3JQXWxQNCIKfEnhMnkBmjijQ/0kba1mS2G45tlMDMPA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.38.0':
resolution: {integrity: sha512-gMhvh0dQ40kjj7gOOWTkYaD2CTq/omh2bii0w8SUnrRERg/mIj03dCjay6sViG75WdMpoTuDlvQ4wXlG633rpA==}
'@mariozechner/pi-tui@0.40.0':
resolution: {integrity: sha512-fWp8hxpQq7PB2GxQN3dOCfy40e2kk3y0oPw9gSVsDxCjCeIZ1y9TYGHU8k2yrdz5I5B2TVpkvsjE6Z6Q5FdU1w==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
@@ -2007,8 +2004,8 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.7.1:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
@@ -3548,7 +3545,7 @@ snapshots:
lit: 3.3.2
signal-polyfill: 0.2.2
'@lit-labs/ssr-dom-shim@1.5.0': {}
'@lit-labs/ssr-dom-shim@1.5.1': {}
'@lit/context@1.1.6':
dependencies:
@@ -3556,7 +3553,7 @@ snapshots:
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.0
'@lit-labs/ssr-dom-shim': 1.5.1
'@mariozechner/clipboard-darwin-arm64@0.3.0':
optional: true
@@ -3614,10 +3611,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.38.0
'@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.40.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3626,7 +3623,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.40.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
@@ -3646,12 +3643,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.38.0(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.40.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.38.0
'@mariozechner/pi-agent-core': 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.40.0
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
@@ -3670,7 +3667,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.38.0':
'@mariozechner/pi-tui@0.40.0':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -4373,7 +4370,7 @@ snapshots:
content-type: 1.0.5
debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.14.1
raw-body: 3.0.2
@@ -4915,7 +4912,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
iconv-lite@0.7.1:
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -5107,7 +5104,7 @@ snapshots:
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.0
'@lit-labs/ssr-dom-shim': 1.5.1
'@lit/reactive-element': 2.1.2
lit-html: 3.3.2
@@ -5539,7 +5536,7 @@ snapshots:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.1
iconv-lite: 0.7.2
unpipe: 1.0.0
react-is@17.0.2:

89
scripts/auth-monitor.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Auth Expiry Monitor
# Run via cron or systemd timer to get proactive notifications
# before Claude Code auth expires.
#
# Suggested cron: */30 * * * * /home/admin/clawdbot/scripts/auth-monitor.sh
#
# Environment variables:
# NOTIFY_PHONE - Phone number to send Clawdbot notification (e.g., +1234567890)
# NOTIFY_NTFY - ntfy.sh topic for push notifications (e.g., clawdbot-alerts)
# WARN_HOURS - Hours before expiry to warn (default: 2)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_CREDS="$HOME/.claude/.credentials.json"
STATE_FILE="$HOME/.clawdbot/auth-monitor-state"
# Configuration
WARN_HOURS="${WARN_HOURS:-2}"
NOTIFY_PHONE="${NOTIFY_PHONE:-}"
NOTIFY_NTFY="${NOTIFY_NTFY:-}"
# State tracking to avoid spam
mkdir -p "$(dirname "$STATE_FILE")"
LAST_NOTIFIED=$(cat "$STATE_FILE" 2>/dev/null || echo "0")
NOW=$(date +%s)
# Only notify once per hour max
MIN_INTERVAL=3600
send_notification() {
local message="$1"
local priority="${2:-default}"
echo "$(date '+%Y-%m-%d %H:%M:%S') - $message"
# Check if we notified recently
if [ $((NOW - LAST_NOTIFIED)) -lt $MIN_INTERVAL ]; then
echo "Skipping notification (sent recently)"
return
fi
# Send via Clawdbot if phone configured and auth still valid
if [ -n "$NOTIFY_PHONE" ]; then
# Check if we can still use clawdbot
if "$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null | grep -q "OK\|EXPIRING"; then
echo "Sending via Clawdbot to $NOTIFY_PHONE..."
clawdbot send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null || true
fi
fi
# Send via ntfy.sh if configured
if [ -n "$NOTIFY_NTFY" ]; then
echo "Sending via ntfy.sh to $NOTIFY_NTFY..."
curl -s -o /dev/null \
-H "Title: Clawdbot Auth Alert" \
-H "Priority: $priority" \
-H "Tags: warning,key" \
-d "$message" \
"https://ntfy.sh/$NOTIFY_NTFY" || true
fi
# Update state
echo "$NOW" > "$STATE_FILE"
}
# Check auth status
if [ ! -f "$CLAUDE_CREDS" ]; then
send_notification "Claude Code credentials missing! Run: claude setup-token" "high"
exit 1
fi
EXPIRES_AT=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS")
NOW_MS=$((NOW * 1000))
DIFF_MS=$((EXPIRES_AT - NOW_MS))
HOURS_LEFT=$((DIFF_MS / 3600000))
MINS_LEFT=$(((DIFF_MS % 3600000) / 60000))
if [ "$DIFF_MS" -lt 0 ]; then
send_notification "Claude Code auth EXPIRED! Clawdbot is down. Run: ssh l36 '~/clawdbot/scripts/mobile-reauth.sh'" "urgent"
exit 1
elif [ "$HOURS_LEFT" -lt "$WARN_HOURS" ]; then
send_notification "Claude Code auth expires in ${HOURS_LEFT}h ${MINS_LEFT}m. Consider re-auth soon." "high"
exit 0
else
echo "$(date '+%Y-%m-%d %H:%M:%S') - Auth OK: ${HOURS_LEFT}h ${MINS_LEFT}m remaining"
exit 0
fi

280
scripts/claude-auth-status.sh Executable file
View File

@@ -0,0 +1,280 @@
#!/bin/bash
# Claude Code Authentication Status Checker
# Checks both Claude Code and Clawdbot auth status
set -euo pipefail
CLAUDE_CREDS="$HOME/.claude/.credentials.json"
CLAWDBOT_AUTH="$HOME/.clawdbot/agents/main/agent/auth-profiles.json"
# Colors for terminal output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Output mode: "full" (default), "json", or "simple"
OUTPUT_MODE="${1:-full}"
fetch_models_status_json() {
clawdbot models status --json 2>/dev/null || true
}
STATUS_JSON="$(fetch_models_status_json)"
USE_JSON=0
if [ -n "$STATUS_JSON" ]; then
USE_JSON=1
fi
calc_status_from_expires() {
local expires_at="$1"
if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then
expires_at=0
fi
local now_ms=$(( $(date +%s) * 1000 ))
local diff_ms=$((expires_at - now_ms))
local hours=$((diff_ms / 3600000))
local mins=$(((diff_ms % 3600000) / 60000))
if [ "$expires_at" -le 0 ]; then
echo "MISSING"
return 1
elif [ "$diff_ms" -lt 0 ]; then
echo "EXPIRED"
return 1
elif [ "$diff_ms" -lt 3600000 ]; then
echo "EXPIRING:${mins}m"
return 2
else
echo "OK:${hours}h${mins}m"
return 0
fi
}
json_expires_for_claude_cli() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth" and .source == "claude-cli")
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_expires_for_anthropic_any() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| .expiresAt // 0]
| max // 0
' 2>/dev/null || echo "0"
}
json_best_anthropic_profile() {
echo "$STATUS_JSON" | jq -r '
[.auth.oauth.profiles[]
| select(.provider == "anthropic" and .type == "oauth")
| {id: .profileId, exp: (.expiresAt // 0)}]
| sort_by(.exp) | reverse | .[0].id // "none"
' 2>/dev/null || echo "none"
}
json_anthropic_api_key_count() {
echo "$STATUS_JSON" | jq -r '
[.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey]
| max // 0
' 2>/dev/null || echo "0"
}
check_claude_code_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local expires_at
expires_at=$(json_expires_for_claude_cli)
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$CLAUDE_CREDS" ]; then
echo "MISSING"
return 1
fi
local expires_at
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
calc_status_from_expires "$expires_at"
}
check_clawdbot_auth() {
if [ "$USE_JSON" -eq 1 ]; then
local api_keys
api_keys=$(json_anthropic_api_key_count)
if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then
api_keys=0
fi
local expires_at
expires_at=$(json_expires_for_anthropic_any)
if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo "OK:static"
return 0
fi
calc_status_from_expires "$expires_at"
return $?
fi
if [ ! -f "$CLAWDBOT_AUTH" ]; then
echo "MISSING"
return 1
fi
local expires
expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0
' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
calc_status_from_expires "$expires"
}
# JSON output mode
if [ "$OUTPUT_MODE" = "json" ]; then
claude_status=$(check_claude_code_auth 2>/dev/null || true)
clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true)
claude_expires=0
clawdbot_expires=0
if [ "$USE_JSON" -eq 1 ]; then
claude_expires=$(json_expires_for_claude_cli)
clawdbot_expires=$(json_expires_for_anthropic_any)
else
claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
fi
jq -n \
--arg cs "$claude_status" \
--arg ce "$claude_expires" \
--arg bs "$clawdbot_status" \
--arg be "$clawdbot_expires" \
'{
claude_code: {status: $cs, expires_at_ms: ($ce | tonumber)},
clawdbot: {status: $bs, expires_at_ms: ($be | tonumber)},
needs_reauth: (($cs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")) or ($bs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")))
}'
exit 0
fi
# Simple output mode (for scripts/widgets)
if [ "$OUTPUT_MODE" = "simple" ]; then
claude_status=$(check_claude_code_auth 2>/dev/null || true)
clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true)
if [[ "$claude_status" == EXPIRED* ]] || [[ "$claude_status" == MISSING* ]]; then
echo "CLAUDE_EXPIRED"
exit 1
elif [[ "$clawdbot_status" == EXPIRED* ]] || [[ "$clawdbot_status" == MISSING* ]]; then
echo "CLAWDBOT_EXPIRED"
exit 1
elif [[ "$claude_status" == EXPIRING* ]]; then
echo "CLAUDE_EXPIRING"
exit 2
elif [[ "$clawdbot_status" == EXPIRING* ]]; then
echo "CLAWDBOT_EXPIRING"
exit 2
else
echo "OK"
exit 0
fi
fi
# Full output mode (default)
echo "=== Claude Code Auth Status ==="
echo ""
# Claude Code credentials
echo "Claude Code (~/.claude/.credentials.json):"
if [ "$USE_JSON" -eq 1 ]; then
expires_at=$(json_expires_for_claude_cli)
else
expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0")
fi
if [ -f "$CLAUDE_CREDS" ]; then
sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown")
echo " Subscription: $sub_type"
echo " Rate tier: $rate_tier"
fi
if [ "$expires_at" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Action needed: Run 'claude setup-token'"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires_at - now_ms))
hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}"
echo " Action needed: Run 'claude setup-token' or re-authenticate"
elif [ "$diff_ms" -lt 3600000 ]; then
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
echo " Consider running: claude setup-token"
else
echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)"
fi
fi
echo ""
echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):"
if [ "$USE_JSON" -eq 1 ]; then
best_profile=$(json_best_anthropic_profile)
expires=$(json_expires_for_anthropic_any)
api_keys=$(json_anthropic_api_key_count)
else
best_profile=$(jq -r '
.profiles | to_entries
| map(select(.value.provider == "anthropic"))
| sort_by(.value.expires) | reverse
| .[0].key // "none"
' "$CLAWDBOT_AUTH" 2>/dev/null || echo "none")
expires=$(jq -r '
[.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires]
| max // 0
' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0")
api_keys=0
fi
echo " Profile: $best_profile"
if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then
echo -e " Status: ${GREEN}OK${NC} (API key)"
elif [ "$expires" -le 0 ]; then
echo -e " Status: ${RED}NOT FOUND${NC}"
echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code"
else
now_ms=$(( $(date +%s) * 1000 ))
diff_ms=$((expires - now_ms))
hours=$((diff_ms / 3600000))
mins=$(((diff_ms % 3600000) / 60000))
if [ "$diff_ms" -lt 0 ]; then
echo -e " Status: ${RED}EXPIRED${NC}"
echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code"
elif [ "$diff_ms" -lt 3600000 ]; then
echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}"
else
echo -e " Status: ${GREEN}OK${NC}"
echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)"
fi
fi
echo ""
echo "=== Service Status ==="
if systemctl --user is-active clawdbot >/dev/null 2>&1; then
echo -e "Clawdbot service: ${GREEN}running${NC}"
else
echo -e "Clawdbot service: ${RED}NOT running${NC}"
fi

84
scripts/mobile-reauth.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Mobile-friendly Claude Code re-authentication
# Designed for use via SSH from Termux
#
# This script handles the authentication flow in a way that works
# from a mobile device by:
# 1. Checking if auth is needed
# 2. Running claude setup-token for long-lived auth
# 3. Outputting URLs that can be easily opened on phone
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
echo "=== Claude Code Mobile Re-Auth ==="
echo ""
# Check current auth status
echo "Checking auth status..."
AUTH_STATUS=$("$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null || echo "ERROR")
case "$AUTH_STATUS" in
OK)
echo -e "${GREEN}Auth is valid!${NC}"
"$SCRIPT_DIR/claude-auth-status.sh" full
exit 0
;;
CLAUDE_EXPIRING|CLAWDBOT_EXPIRING)
echo -e "${YELLOW}Auth is expiring soon.${NC}"
echo ""
;;
*)
echo -e "${RED}Auth needs refresh.${NC}"
echo ""
;;
esac
echo "Starting long-lived token setup..."
echo ""
echo -e "${CYAN}Instructions:${NC}"
echo "1. Open this URL on your phone:"
echo ""
echo -e " ${CYAN}https://console.anthropic.com/settings/api-keys${NC}"
echo ""
echo "2. Sign in if needed"
echo "3. Create a new API key or use existing 'Claude Code' key"
echo "4. Copy the key (starts with sk-ant-...)"
echo "5. When prompted below, paste the key"
echo ""
echo "Press Enter when ready to continue..."
read -r
# Run setup-token interactively
echo ""
echo "Running 'claude setup-token'..."
echo "(Follow the prompts and paste your API key when asked)"
echo ""
if claude setup-token; then
echo ""
echo -e "${GREEN}Authentication successful!${NC}"
echo ""
"$SCRIPT_DIR/claude-auth-status.sh" full
# Restart clawdbot service if running
if systemctl --user is-active clawdbot >/dev/null 2>&1; then
echo ""
echo "Restarting clawdbot service..."
systemctl --user restart clawdbot
echo -e "${GREEN}Service restarted.${NC}"
fi
else
echo ""
echo -e "${RED}Authentication failed.${NC}"
echo "Please try again or check the Claude Code documentation."
exit 1
fi

119
scripts/setup-auth-system.sh Executable file
View File

@@ -0,0 +1,119 @@
#!/bin/bash
# Setup Clawdbot Auth Management System
# Run this once to set up:
# 1. Long-lived Claude Code token
# 2. Auth monitoring with notifications
# 3. Instructions for Termux widgets
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== Clawdbot Auth System Setup ==="
echo ""
# Step 1: Check current auth status
echo "Step 1: Checking current auth status..."
"$SCRIPT_DIR/claude-auth-status.sh" full || true
echo ""
# Step 2: Set up long-lived token
echo "Step 2: Long-lived token setup"
echo ""
echo "Option A: Use 'claude setup-token' (recommended)"
echo " - Creates a long-lived API token"
echo " - No daily re-auth needed"
echo " - Run: claude setup-token"
echo ""
echo "Would you like to set up a long-lived token now? [y/N]"
read -r SETUP_TOKEN
if [[ "$SETUP_TOKEN" =~ ^[Yy] ]]; then
echo ""
echo "Opening https://console.anthropic.com/settings/api-keys"
echo "Create a new key or copy existing one, then paste below."
echo ""
claude setup-token
fi
echo ""
# Step 3: Set up auth monitoring
echo "Step 3: Auth monitoring setup"
echo ""
echo "The auth monitor checks expiry every 30 minutes and notifies you."
echo ""
echo "Configure notification channels:"
echo ""
# Check for ntfy
echo " ntfy.sh: Free push notifications to your phone"
echo " 1. Install ntfy app on your phone"
echo " 2. Subscribe to a topic (e.g., 'clawdbot-alerts')"
echo ""
echo "Enter ntfy.sh topic (or leave blank to skip):"
read -r NTFY_TOPIC
# Phone notification
echo ""
echo " Clawdbot message: Send warning via Clawdbot itself"
echo "Enter your phone number for alerts (or leave blank to skip):"
read -r PHONE_NUMBER
# Update service file
SERVICE_FILE="$SCRIPT_DIR/systemd/clawdbot-auth-monitor.service"
if [ -n "$NTFY_TOPIC" ]; then
sed -i "s|# Environment=NOTIFY_NTFY=.*|Environment=NOTIFY_NTFY=$NTFY_TOPIC|" "$SERVICE_FILE"
fi
if [ -n "$PHONE_NUMBER" ]; then
sed -i "s|# Environment=NOTIFY_PHONE=.*|Environment=NOTIFY_PHONE=$PHONE_NUMBER|" "$SERVICE_FILE"
fi
# Install systemd units
echo ""
echo "Installing systemd timer..."
mkdir -p ~/.config/systemd/user
cp "$SCRIPT_DIR/systemd/clawdbot-auth-monitor.service" ~/.config/systemd/user/
cp "$SCRIPT_DIR/systemd/clawdbot-auth-monitor.timer" ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now clawdbot-auth-monitor.timer
echo "Auth monitor installed and running."
echo ""
# Step 4: Termux widget setup
echo "Step 4: Termux widget setup (for phone)"
echo ""
echo "To set up quick auth from your phone:"
echo ""
echo "1. Install Termux and Termux:Widget from F-Droid"
echo "2. Create ~/.shortcuts/ directory in Termux:"
echo " mkdir -p ~/.shortcuts"
echo ""
echo "3. Copy the widget scripts:"
echo " scp $SCRIPT_DIR/termux-quick-auth.sh phone:~/.shortcuts/ClawdAuth"
echo " scp $SCRIPT_DIR/termux-auth-widget.sh phone:~/.shortcuts/ClawdAuth-Full"
echo ""
echo "4. Make them executable on phone:"
echo " ssh phone 'chmod +x ~/.shortcuts/Clawd*'"
echo ""
echo "5. Add Termux:Widget to your home screen"
echo "6. Tap the widget to see your auth scripts"
echo ""
echo "The quick widget (ClawdAuth) shows status and opens auth URL if needed."
echo "The full widget (ClawdAuth-Full) provides guided re-auth flow."
echo ""
# Summary
echo "=== Setup Complete ==="
echo ""
echo "What's configured:"
echo " - Auth status: $SCRIPT_DIR/claude-auth-status.sh"
echo " - Mobile re-auth: $SCRIPT_DIR/mobile-reauth.sh"
echo " - Auth monitor: systemctl --user status clawdbot-auth-monitor.timer"
echo ""
echo "Quick commands:"
echo " Check auth: $SCRIPT_DIR/claude-auth-status.sh"
echo " Re-auth: $SCRIPT_DIR/mobile-reauth.sh"
echo " Test monitor: $SCRIPT_DIR/auth-monitor.sh"
echo ""

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Clawdbot Auth Expiry Monitor
After=network.target
[Service]
Type=oneshot
ExecStart=/home/admin/clawdbot/scripts/auth-monitor.sh
# Configure notification channels via environment
Environment=WARN_HOURS=2
# Environment=NOTIFY_PHONE=+1234567890
# Environment=NOTIFY_NTFY=clawdbot-alerts
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Check Clawdbot auth expiry every 30 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,81 @@
#!/data/data/com.termux/files/usr/bin/bash
# Clawdbot Auth Widget for Termux
# Place in ~/.shortcuts/ for Termux:Widget
#
# This widget checks auth status and helps with re-auth if needed.
# It's designed for quick one-tap checking from phone home screen.
# Server hostname (via Tailscale or SSH config)
SERVER="${CLAWDBOT_SERVER:-l36}"
# Check auth status
termux-toast "Checking Clawdbot auth..."
STATUS=$(ssh "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh simple' 2>&1)
EXIT_CODE=$?
case "$STATUS" in
OK)
# Get remaining time
DETAILS=$(ssh "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh json' 2>&1)
HOURS=$(echo "$DETAILS" | jq -r '.claude_code.status' | grep -oP '\d+(?=h)' || echo "?")
termux-vibrate -d 50
termux-toast "Auth OK (${HOURS}h left)"
;;
CLAUDE_EXPIRING|CLAWDBOT_EXPIRING)
termux-vibrate -d 100
# Ask if user wants to re-auth now
CHOICE=$(termux-dialog radio -t "Auth Expiring Soon" -v "Re-auth now,Check later,Dismiss")
SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"')
case "$SELECTED" in
"Re-auth now")
termux-toast "Opening auth page..."
termux-open-url "https://console.anthropic.com/settings/api-keys"
# Show instructions
termux-dialog confirm -t "Re-auth Instructions" -i "1. Create/copy API key from browser
2. Return here and tap OK
3. SSH to server and paste key"
# Open terminal to server
am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN
termux-toast "Run: ssh $SERVER '$HOME/clawdbot/scripts/mobile-reauth.sh'"
;;
*)
termux-toast "Reminder: Auth expires soon"
;;
esac
;;
CLAUDE_EXPIRED|CLAWDBOT_EXPIRED)
termux-vibrate -d 300
CHOICE=$(termux-dialog radio -t "Auth Expired!" -v "Re-auth now,Dismiss")
SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"')
case "$SELECTED" in
"Re-auth now")
termux-toast "Opening auth page..."
termux-open-url "https://console.anthropic.com/settings/api-keys"
termux-dialog confirm -t "Re-auth Steps" -i "1. Create/copy API key from browser
2. Return here and tap OK to SSH"
am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN
termux-toast "Run: ssh $SERVER '$HOME/clawdbot/scripts/mobile-reauth.sh'"
;;
*)
termux-toast "Warning: Clawdbot won't work until re-auth"
;;
esac
;;
*)
termux-vibrate -d 200
termux-toast "Error: $STATUS"
;;
esac

View File

@@ -0,0 +1,30 @@
#!/data/data/com.termux/files/usr/bin/bash
# Quick Auth Check - Minimal widget for Termux
# Place in ~/.shortcuts/ for Termux:Widget
#
# One-tap: shows status toast
# If expired: directly opens auth URL
SERVER="${CLAWDBOT_SERVER:-l36}"
STATUS=$(ssh -o ConnectTimeout=5 "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh simple' 2>&1)
case "$STATUS" in
OK)
termux-toast -s "Auth OK"
;;
*EXPIRING*)
termux-vibrate -d 100
termux-toast "Auth expiring soon - tap again if needed"
;;
*EXPIRED*|*MISSING*)
termux-vibrate -d 200
termux-toast "Auth expired - opening console..."
termux-open-url "https://console.anthropic.com/settings/api-keys"
sleep 2
termux-notification -t "Clawdbot Re-Auth" -c "After getting key, run: ssh $SERVER '~/clawdbot/scripts/mobile-reauth.sh'" --id clawd-auth
;;
*)
termux-toast "Connection error"
;;
esac

View File

@@ -0,0 +1,24 @@
#!/data/data/com.termux/files/usr/bin/bash
# Clawdbot OAuth Sync Widget
# Syncs Claude Code tokens to Clawdbot on l36 server
# Place in ~/.shortcuts/ on phone for Termux:Widget
termux-toast "Syncing Clawdbot auth..."
# Run sync on l36 server
RESULT=$(ssh l36 '/home/admin/clawdbot/scripts/sync-claude-code-auth.sh' 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
# Extract expiry time from output
EXPIRY=$(echo "$RESULT" | grep "Token expires:" | cut -d: -f2-)
termux-vibrate -d 100
termux-toast "Clawdbot synced! Expires:${EXPIRY}"
# Optional: restart clawdbot service
ssh l36 'systemctl --user restart clawdbot' 2>/dev/null
else
termux-vibrate -d 300
termux-toast "Sync failed: ${RESULT}"
fi

View File

@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000;
afterEach(() => {
vi.restoreAllMocks();
});
it("classifies OAuth and API key profiles", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {
version: 1,
profiles: {
"anthropic:ok": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
},
"anthropic:expiring": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + 10_000,
},
"anthropic:expired": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now - 10_000,
},
"anthropic:api": {
type: "api_key" as const,
provider: "anthropic",
key: "sk-ant-api",
},
},
};
const summary = buildAuthHealthSummary({
store,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const statuses = Object.fromEntries(
summary.profiles.map((profile) => [profile.profileId, profile.status]),
);
expect(statuses["anthropic:ok"]).toBe("ok");
expect(statuses["anthropic:expiring"]).toBe("expiring");
expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find(
(entry) => entry.provider === "anthropic",
);
expect(provider?.status).toBe("expired");
});
});

227
src/agents/auth-health.ts Normal file
View File

@@ -0,0 +1,227 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProfileHealth = {
profileId: string;
provider: string;
type: "oauth" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
source: AuthProfileSource;
label: string;
};
export type AuthProviderHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProviderHealth = {
provider: string;
status: AuthProviderHealthStatus;
expiresAt?: number;
remainingMs?: number;
profiles: AuthProfileHealth[];
};
export type AuthHealthSummary = {
now: number;
warnAfterMs: number;
profiles: AuthProfileHealth[];
providers: AuthProviderHealth[];
};
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
return "store";
}
export function formatRemainingShort(remainingMs?: number): string {
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
if (remainingMs <= 0) return "0m";
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
function resolveOAuthStatus(
expiresAt: number | undefined,
now: number,
warnAfterMs: number,
): { status: AuthProfileHealthStatus; remainingMs?: number } {
if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) {
return { status: "missing" };
}
const remainingMs = expiresAt - now;
if (remainingMs <= 0) {
return { status: "expired", remainingMs };
}
if (remainingMs <= warnAfterMs) {
return { status: "expiring", remainingMs };
}
return { status: "ok", remainingMs };
}
function buildProfileHealth(params: {
profileId: string;
credential: AuthProfileCredential;
store: AuthProfileStore;
cfg?: ClawdbotConfig;
now: number;
warnAfterMs: number;
}): AuthProfileHealth {
const { profileId, credential, store, cfg, now, warnAfterMs } = params;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const source = resolveAuthProfileSource(profileId);
if (credential.type === "api_key") {
return {
profileId,
provider: credential.provider,
type: "api_key",
status: "static",
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "oauth",
status,
expiresAt: credential.expires,
remainingMs,
source,
label,
};
}
export function buildAuthHealthSummary(params: {
store: AuthProfileStore;
cfg?: ClawdbotConfig;
warnAfterMs?: number;
providers?: string[];
}): AuthHealthSummary {
const now = Date.now();
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
const providerFilter = params.providers
? new Set(params.providers.map((p) => p.trim()).filter(Boolean))
: null;
const profiles = Object.entries(params.store.profiles)
.filter(([_, cred]) =>
providerFilter ? providerFilter.has(cred.provider) : true,
)
.map(([profileId, credential]) =>
buildProfileHealth({
profileId,
credential,
store: params.store,
cfg: params.cfg,
now,
warnAfterMs,
}),
)
.sort((a, b) => {
if (a.provider !== b.provider) {
return a.provider.localeCompare(b.provider);
}
return a.profileId.localeCompare(b.profileId);
});
const providersMap = new Map<string, AuthProviderHealth>();
for (const profile of profiles) {
const existing = providersMap.get(profile.provider);
if (!existing) {
providersMap.set(profile.provider, {
provider: profile.provider,
status: "missing",
profiles: [profile],
});
} else {
existing.profiles.push(profile);
}
}
if (providerFilter) {
for (const provider of providerFilter) {
if (!providersMap.has(provider)) {
providersMap.set(provider, {
provider,
status: "missing",
profiles: [],
});
}
}
}
for (const provider of providersMap.values()) {
if (provider.profiles.length === 0) {
provider.status = "missing";
continue;
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
if (oauthProfiles.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
const expiryCandidates = oauthProfiles
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
provider.expiresAt = Math.min(...expiryCandidates);
provider.remainingMs = provider.expiresAt - now;
}
const statuses = oauthProfiles.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {
provider.status = "expiring";
} else {
provider.status = "ok";
}
}
const providers = Array.from(providersMap.values()).sort((a, b) =>
a.provider.localeCompare(b.provider),
);
return { now, warnAfterMs, profiles, providers };
}

View File

@@ -159,6 +159,9 @@ describe("directive behavior", () => {
expect(text).toContain(
"Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.",
);
expect(text).toContain(
"Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -182,6 +185,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -204,6 +208,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -358,6 +363,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -380,6 +386,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -403,6 +410,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current verbose level: on");
expect(text).toContain("Options: on, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -425,6 +433,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current reasoning level: off");
expect(text).toContain("Options: on, off, stream.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -458,6 +467,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -54,6 +54,9 @@ import {
} from "./queue.js";
const SYSTEM_MARK = "⚙️";
const formatOptionsLine = (options: string) => `Options: ${options}.`;
const withOptions = (line: string, options: string) =>
`${line}\n${formatOptionsLine(options)}`;
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
@@ -417,7 +420,12 @@ export async function handleDirectiveOnly(params: {
// If no argument was provided, show the current level
if (!directives.rawThinkLevel) {
const level = currentThinkLevel ?? "off";
return { text: `Current thinking level: ${level}.` };
return {
text: withOptions(
`Current thinking level: ${level}.`,
"off, minimal, low, medium, high",
),
};
}
return {
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`,
@@ -426,7 +434,9 @@ export async function handleDirectiveOnly(params: {
if (directives.hasVerboseDirective && !directives.verboseLevel) {
if (!directives.rawVerboseLevel) {
const level = currentVerboseLevel ?? "off";
return { text: `Current verbose level: ${level}.` };
return {
text: withOptions(`Current verbose level: ${level}.`, "on, off"),
};
}
return {
text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`,
@@ -435,7 +445,12 @@ export async function handleDirectiveOnly(params: {
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
if (!directives.rawReasoningLevel) {
const level = currentReasoningLevel ?? "off";
return { text: `Current reasoning level: ${level}.` };
return {
text: withOptions(
`Current reasoning level: ${level}.`,
"on, off, stream",
),
};
}
return {
text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`,
@@ -447,7 +462,9 @@ export async function handleDirectiveOnly(params: {
return { text: "elevated is not available right now." };
}
const level = currentElevatedLevel ?? "off";
return { text: `Current elevated level: ${level}.` };
return {
text: withOptions(`Current elevated level: ${level}.`, "on, off"),
};
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
@@ -483,7 +500,10 @@ export async function handleDirectiveOnly(params: {
typeof settings.cap === "number" ? String(settings.cap) : "default";
const dropLabel = settings.dropPolicy ?? "default";
return {
text: `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
text: withOptions(
`Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
"modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize",
),
};
}

View File

@@ -90,8 +90,10 @@ describe("gateway SIGTERM", () => {
const err: string[] = [];
child = spawn(
"bun",
process.execPath,
[
"--import",
"tsx",
"src/index.ts",
"gateway",
"--port",

View File

@@ -1,6 +1,9 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { parseLogLine } from "../logging/parse-log-line.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
type LogsTailPayload = {
@@ -18,6 +21,8 @@ type LogsCliOptions = {
follow?: boolean;
interval?: string;
json?: boolean;
plain?: boolean;
color?: boolean;
url?: string;
token?: string;
timeout?: string;
@@ -47,6 +52,92 @@ async function fetchLogs(
return payload as LogsTailPayload;
}
function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
if (mode === "pretty") return parsed.toISOString().slice(11, 19);
return parsed.toISOString();
}
function formatLogLine(
raw: string,
opts: {
pretty: boolean;
rich: boolean;
},
): string {
const parsed = parseLogLine(raw);
if (!parsed) return raw;
const label = parsed.subsystem ?? parsed.module ?? "";
const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain");
const level = parsed.level ?? "";
const levelLabel = level.padEnd(5).trim();
const message = parsed.message || parsed.raw;
if (!opts.pretty) {
return [time, level, label, message].filter(Boolean).join(" ").trim();
}
const timeLabel = colorize(opts.rich, theme.muted, time);
const labelValue = colorize(opts.rich, theme.accent, label);
const levelValue =
level === "error" || level === "fatal"
? colorize(opts.rich, theme.error, levelLabel)
: level === "warn"
? colorize(opts.rich, theme.warn, levelLabel)
: level === "debug" || level === "trace"
? colorize(opts.rich, theme.muted, levelLabel)
: colorize(opts.rich, theme.info, levelLabel);
const messageValue =
level === "error" || level === "fatal"
? colorize(opts.rich, theme.error, message)
: level === "warn"
? colorize(opts.rich, theme.warn, message)
: level === "debug" || level === "trace"
? colorize(opts.rich, theme.muted, message)
: colorize(opts.rich, theme.info, message);
const head = [timeLabel, levelValue, labelValue].filter(Boolean).join(" ");
return [head, messageValue].filter(Boolean).join(" ").trim();
}
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
const text = `${JSON.stringify(payload)}\n`;
if (toStdErr) process.stderr.write(text);
else process.stdout.write(text);
}
function emitGatewayError(
err: unknown,
opts: LogsCliOptions,
mode: "json" | "text",
rich: boolean,
) {
const details = buildGatewayConnectionDetails({ url: opts.url });
const message = "Gateway not reachable. Is it running and accessible?";
const hint = "Hint: run `clawdbot doctor`.";
const errorText = err instanceof Error ? err.message : String(err);
if (mode === "json") {
emitJsonLine(
{
type: "error",
message,
error: errorText,
details,
hint,
},
true,
);
return;
}
defaultRuntime.error(colorize(rich, theme.error, message));
defaultRuntime.error(details.message);
defaultRuntime.error(colorize(rich, theme.muted, hint));
}
export function registerLogsCli(program: Command) {
const logs = program
.command("logs")
@@ -55,7 +146,9 @@ export function registerLogsCli(program: Command) {
.option("--max-bytes <n>", "Max bytes to read", "250000")
.option("--follow", "Follow log output", false)
.option("--interval <ms>", "Polling interval in ms", "1000")
.option("--json", "Emit JSON payloads", false);
.option("--json", "Emit JSON log lines", false)
.option("--plain", "Plain text output (no ANSI styling)", false)
.option("--no-color", "Disable ANSI colors");
addGatewayClientOptions(logs);
@@ -63,18 +156,63 @@ export function registerLogsCli(program: Command) {
const interval = parsePositiveInt(opts.interval, 1000);
let cursor: number | undefined;
let first = true;
const jsonMode = Boolean(opts.json);
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
const rich = isRich() && opts.color !== false;
while (true) {
const payload = await fetchLogs(opts, cursor);
let payload: LogsTailPayload;
try {
payload = await fetchLogs(opts, cursor);
} catch (err) {
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
defaultRuntime.exit(1);
return;
}
const lines = Array.isArray(payload.lines) ? payload.lines : [];
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
} else {
if (first && payload.file) {
defaultRuntime.log(`Log file: ${payload.file}`);
if (jsonMode) {
if (first) {
emitJsonLine({
type: "meta",
file: payload.file,
cursor: payload.cursor,
size: payload.size,
});
}
for (const line of lines) {
defaultRuntime.log(line);
const parsed = parseLogLine(line);
if (parsed) {
emitJsonLine({ type: "log", ...parsed });
} else {
emitJsonLine({ type: "raw", raw: line });
}
}
if (payload.truncated) {
emitJsonLine({
type: "notice",
message: "Log tail truncated (increase --max-bytes).",
});
}
if (payload.reset) {
emitJsonLine({
type: "notice",
message: "Log cursor reset (file rotated).",
});
}
} else {
if (first && payload.file) {
const prefix = pretty
? colorize(rich, theme.muted, "Log file:")
: "Log file:";
defaultRuntime.log(`${prefix} ${payload.file}`);
}
for (const line of lines) {
defaultRuntime.log(
formatLogLine(line, {
pretty,
rich,
}),
);
}
if (payload.truncated) {
defaultRuntime.error("Log tail truncated (increase --max-bytes).");

View File

@@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) {
.description("Show configured model state")
.option("--json", "Output JSON", false)
.option("--plain", "Plain output", false)
.option(
"--check",
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
.action(async (opts) => {
try {
await modelsStatusCommand(opts, defaultRuntime);

View File

@@ -232,9 +232,10 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax|skip",
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--gemini-api-key <key>", "Gemini API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
@@ -263,11 +264,13 @@ export function buildProgram() {
| "openai-codex"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax"
| "skip"
| undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
gatewayPort:
typeof opts.gatewayPort === "string"
? Number.parseInt(opts.gatewayPort, 10)

View File

@@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: {
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {

View File

@@ -25,11 +25,16 @@ import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxProviderConfig,
setAnthropicApiKey,
setGeminiApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
@@ -415,6 +420,30 @@ export async function applyAuthChoice(params: {
"OAuth help",
);
}
} else if (params.authChoice === "gemini-api-key") {
const key = await params.prompter.text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setGeminiApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
if (params.setDefaultModel) {
const applied = applyGoogleGeminiModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL;
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
}
} else if (params.authChoice === "apiKey") {
const key = await params.prompter.text({
message: "Enter Anthropic API key",

View File

@@ -50,11 +50,16 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
setAnthropicApiKey,
setGeminiApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
@@ -300,6 +305,7 @@ async function promptAuthConfig(
| "openai-codex"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax"
| "skip";
@@ -513,6 +519,28 @@ async function promptAuthConfig(
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "gemini-api-key") {
const key = guardCancel(
await text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setGeminiApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
const applied = applyGoogleGeminiModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({

View File

@@ -1,8 +1,16 @@
import { note } from "@clack/prompts";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../agents/auth-health.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
repairOAuthProfileIdMismatch,
resolveApiKeyForProfile,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId(
if (!apply) return cfg;
return repair.config;
}
type AuthIssue = {
profileId: string;
provider: string;
status: string;
remainingMs?: number;
};
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (
issue.provider === "anthropic" &&
issue.profileId === CLAUDE_CLI_PROFILE_ID
) {
return "Run `claude setup-token` on the gateway host.";
}
if (
issue.provider === "openai-codex" &&
issue.profileId === CODEX_CLI_PROFILE_ID
) {
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
}
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
}
function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined
? ` (${formatRemainingShort(issue.remainingMs)})`
: "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
}
export async function noteAuthProfileHealth(params: {
cfg: ClawdbotConfig;
prompter: DoctorPrompter;
allowKeychainPrompt: boolean;
}): Promise<void> {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
let summary = buildAuthHealthSummary({
store,
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const findIssues = () =>
summary.profiles.filter(
(profile) =>
profile.type === "oauth" &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
);
let issues = findIssues();
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now?",
initialValue: true,
});
if (shouldRefresh) {
const refreshTargets = issues.filter((issue) =>
["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
try {
await resolveApiKeyForProfile({
cfg: params.cfg,
store,
profileId: profile.profileId,
});
} catch (err) {
errors.push(
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (errors.length > 0) {
note(errors.join("\n"), "OAuth refresh errors");
}
summary = buildAuthHealthSummary({
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
issues = findIssues();
}
if (issues.length > 0) {
note(
issues
.map((issue) =>
formatAuthIssueLine({
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
remainingMs: issue.remainingMs,
}),
)
.join("\n"),
"Model auth",
);
}
}

View File

@@ -26,7 +26,10 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
@@ -124,6 +127,12 @@ export async function doctorCommand(
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,
allowKeychainPrompt:
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
});
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) {
note(gatewayDetails.remoteFallbackNote, "Gateway");

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("overrides existing model", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("no-ops when already gemini default", () => {
const cfg: ClawdbotConfig = {
agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
});
});

View File

@@ -0,0 +1,38 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview";
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model)?.trim();
if (current === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL }
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
},
},
changed: true,
};
}

View File

@@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({
resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir,
}));
vi.mock("../../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
}));
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../agents/auth-profiles.js")>();
return {
...actual,
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
listProfilesForProvider: mocks.listProfilesForProvider,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay,
};
});
vi.mock("../../agents/model-auth.js", () => ({
resolveEnvApiKey: mocks.resolveEnvApiKey,
@@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => {
expect(payload.auth.shellEnvFallback.appliedKeys).toContain(
"OPENAI_API_KEY",
);
expect(payload.auth.missingProvidersInUse).toEqual([]);
expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0);
expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0);
const providers = payload.auth.providers as Array<{
provider: string;
@@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => {
),
).toBe(true);
});
it("exits non-zero when auth is missing", async () => {
const originalProfiles = { ...mocks.store.profiles };
mocks.store.profiles = {};
const localRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation();
mocks.resolveEnvApiKey.mockImplementation(() => null);
try {
await modelsStatusCommand(
{ check: true, plain: true },
localRuntime as never,
);
expect(localRuntime.exit).toHaveBeenCalledWith(1);
} finally {
mocks.store.profiles = originalProfiles;
mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl);
}
});
});

View File

@@ -7,6 +7,11 @@ import {
} from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../../agents/auth-health.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@@ -599,7 +604,7 @@ export async function modelsListCommand(
}
export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean },
opts: { json?: boolean; plain?: boolean; check?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
@@ -656,6 +661,7 @@ export async function modelsStatusCommand(
.filter(Boolean),
);
const providersFromModels = new Set<string>();
const providersInUse = new Set<string>();
for (const raw of [
defaultLabel,
...fallbacks,
@@ -666,6 +672,15 @@ export async function modelsStatusCommand(
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersFromModels.add(parsed.provider);
}
for (const raw of [
defaultLabel,
...fallbacks,
imageModel,
...imageFallbacks,
]) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (parsed?.provider) providersInUse.add(parsed.provider);
}
const providersFromEnv = new Set<string>();
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
@@ -715,6 +730,12 @@ export async function modelsStatusCommand(
Boolean(entry.modelsJson);
return hasAny;
});
const providerAuthMap = new Map(
providerAuth.map((entry) => [entry.provider, entry]),
);
const missingProvidersInUse = Array.from(providersInUse)
.filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b));
const providersWithOauth = providerAuth
.filter(
@@ -726,6 +747,29 @@ export async function modelsStatusCommand(
return `${entry.provider} (${count})`;
});
const authHealth = buildAuthHealthSummary({
store,
cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
providers,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth",
);
const checkStatus = (() => {
const hasExpiredOrMissing =
oauthProfiles.some((profile) =>
["expired", "missing"].includes(profile.status),
) || missingProvidersInUse.length > 0;
const hasExpiring = oauthProfiles.some(
(profile) => profile.status === "expiring",
);
if (hasExpiredOrMissing) return 1;
if (hasExpiring) return 2;
return 0;
})();
if (opts.json) {
runtime.log(
JSON.stringify(
@@ -746,18 +790,30 @@ export async function modelsStatusCommand(
appliedKeys: applied,
},
providersWithOAuth: providersWithOauth,
missingProvidersInUse,
providers: providerAuth,
oauth: {
warnAfterMs: authHealth.warnAfterMs,
profiles: authHealth.profiles,
providers: authHealth.providers,
},
},
},
null,
2,
),
);
if (opts.check) {
runtime.exit(checkStatus);
}
return;
}
if (opts.plain) {
runtime.log(resolvedLabel);
if (opts.check) {
runtime.exit(checkStatus);
}
return;
}
@@ -933,4 +989,48 @@ export async function modelsStatusCommand(
}
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
}
if (missingProvidersInUse.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Missing auth"));
for (const provider of missingProvidersInUse) {
const hint =
provider === "anthropic"
? "Run `claude setup-token` or `clawdbot configure`."
: "Run `clawdbot configure` or set an API key env var.";
runtime.log(`- ${theme.heading(provider)} ${hint}`);
}
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "OAuth status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
for (const profile of oauthProfiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry = profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`)
: "";
runtime.log(`- ${label} ${status}${expiry}${source}`);
}
if (opts.check) {
runtime.exit(checkStatus);
}
}

View File

@@ -33,6 +33,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
});
}
export async function setGeminiApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
upsertAuthProfile({
profileId: "google:default",
credential: {
type: "api_key",
provider: "google",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
});
}
export function applyAuthProfileConfig(
cfg: ClawdbotConfig,
params: {

View File

@@ -23,11 +23,13 @@ import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
setAnthropicApiKey,
setGeminiApiKey,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
@@ -119,6 +121,20 @@ export async function runNonInteractiveOnboarding(
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "gemini-api-key") {
const key = opts.geminiApiKey?.trim();
if (!key) {
runtime.error("Missing --gemini-api-key");
runtime.exit(1);
return;
}
await setGeminiApiKey(key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,

View File

@@ -9,6 +9,7 @@ export type AuthChoice =
| "codex-cli"
| "antigravity"
| "apiKey"
| "gemini-api-key"
| "minimax"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
@@ -24,6 +25,7 @@ export type OnboardOptions = {
nonInteractive?: boolean;
authChoice?: AuthChoice;
anthropicApiKey?: string;
geminiApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import { getResolvedLoggerSettings } from "../../logging.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
@@ -10,14 +11,7 @@ export type ProvidersLogsOptions = {
json?: boolean;
};
type LogLine = {
time?: string;
level?: string;
subsystem?: string;
module?: string;
message: string;
raw: string;
};
type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
@@ -37,59 +31,7 @@ function parseProviderFilter(raw?: string) {
return PROVIDERS.has(trimmed) ? trimmed : "all";
}
function extractMessage(value: Record<string, unknown>): string {
const parts: string[] = [];
for (const key of Object.keys(value)) {
if (!/^\d+$/.test(key)) continue;
const item = value[key];
if (typeof item === "string") {
parts.push(item);
} else if (item != null) {
parts.push(JSON.stringify(item));
}
}
return parts.join(" ");
}
function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
if (typeof raw !== "string") return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
subsystem:
typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
module: typeof parsed.module === "string" ? parsed.module : undefined,
};
} catch {
return {};
}
}
function parseLogLine(raw: string): LogLine | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const meta = parsed._meta as Record<string, unknown> | undefined;
const nameMeta = parseMetaName(meta?.name);
return {
time:
typeof parsed.time === "string"
? parsed.time
: typeof meta?.date === "string"
? meta.date
: undefined,
level:
typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined,
subsystem: nameMeta.subsystem,
module: nameMeta.module,
message: extractMessage(parsed),
raw,
};
} catch {
return null;
}
}
function matchesProvider(line: LogLine, provider: string) {
function matchesProvider(line: NonNullable<LogLine>, provider: string) {
if (provider === "all") return true;
const needle = `gateway/providers/${provider}`;
if (line.subsystem?.includes(needle)) return true;
@@ -139,7 +81,7 @@ export async function providersLogsCommand(
const rawLines = await readTailLines(file, limit * 4);
const parsed = rawLines
.map(parseLogLine)
.filter((line): line is LogLine => Boolean(line));
.filter((line): line is NonNullable<LogLine> => Boolean(line));
const filtered = parsed.filter((line) => matchesProvider(line, provider));
const lines = filtered.slice(Math.max(0, filtered.length - limit));

View File

@@ -154,6 +154,10 @@ function buildSystemdUnit({
`ExecStart=${execStart}`,
"Restart=always",
"RestartSec=5",
// KillMode=process ensures systemd only waits for the main process to exit.
// Without this, podman's conmon (container monitor) processes block shutdown
// since they run as children of the gateway and stay in the same cgroup.
"KillMode=process",
workingDirLine,
...envLines,
"",

View File

@@ -785,6 +785,7 @@ export function createDiscordMessageHandler(params: {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && shouldRequireMention) {
if (botId && !wasMentioned && !shouldBypassMention) {
@@ -981,7 +982,7 @@ export function createDiscordMessageHandler(params: {
: undefined,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: wasMentioned,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,

View File

@@ -326,6 +326,7 @@ export async function monitorIMessageProvider(
!mentioned &&
commandAuthorized &&
hasControlCommand(messageText);
const effectiveWasMentioned = mentioned || shouldBypassMention;
if (
isGroup &&
requireMention &&
@@ -387,7 +388,7 @@ export async function monitorIMessageProvider(
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
WasMentioned: mentioned,
WasMentioned: effectiveWasMentioned,
CommandAuthorized: commandAuthorized,
// Originating channel for reply routing.
OriginatingChannel: "imessage" as const,

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseLogLine } from "./parse-log-line.js";
describe("parseLogLine", () => {
it("parses structured JSON log lines", () => {
const line = JSON.stringify({
time: "2026-01-09T01:38:41.523Z",
0: '{"subsystem":"gateway/providers/whatsapp"}',
1: "connected",
_meta: {
name: '{"subsystem":"gateway/providers/whatsapp"}',
logLevelName: "INFO",
},
});
const parsed = parseLogLine(line);
expect(parsed).not.toBeNull();
expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z");
expect(parsed?.level).toBe("info");
expect(parsed?.subsystem).toBe("gateway/providers/whatsapp");
expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected");
expect(parsed?.raw).toBe(line);
});
it("falls back to meta timestamp when top-level time is missing", () => {
const line = JSON.stringify({
0: "hello",
_meta: {
name: "{\"subsystem\":\"gateway\"}",
logLevelName: "WARN",
date: "2026-01-09T02:10:00.000Z",
},
});
const parsed = parseLogLine(line);
expect(parsed?.time).toBe("2026-01-09T02:10:00.000Z");
expect(parsed?.level).toBe("warn");
});
it("returns null for invalid JSON", () => {
expect(parseLogLine("not-json")).toBeNull();
});
});

View File

@@ -0,0 +1,63 @@
export type ParsedLogLine = {
time?: string;
level?: string;
subsystem?: string;
module?: string;
message: string;
raw: string;
};
function extractMessage(value: Record<string, unknown>): string {
const parts: string[] = [];
for (const key of Object.keys(value)) {
if (!/^\d+$/.test(key)) continue;
const item = value[key];
if (typeof item === "string") {
parts.push(item);
} else if (item != null) {
parts.push(JSON.stringify(item));
}
}
return parts.join(" ");
}
function parseMetaName(
raw?: unknown,
): { subsystem?: string; module?: string } {
if (typeof raw !== "string") return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
subsystem:
typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
module: typeof parsed.module === "string" ? parsed.module : undefined,
};
} catch {
return {};
}
}
export function parseLogLine(raw: string): ParsedLogLine | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const meta = parsed._meta as Record<string, unknown> | undefined;
const nameMeta = parseMetaName(meta?.name);
const levelRaw =
typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined;
return {
time:
typeof parsed.time === "string"
? parsed.time
: typeof meta?.date === "string"
? meta.date
: undefined,
level: levelRaw ? levelRaw.toLowerCase() : undefined,
subsystem: nameMeta.subsystem,
module: nameMeta.module,
message: extractMessage(parsed),
raw,
};
} catch {
return null;
}
}

View File

@@ -27,7 +27,7 @@ const makeModel = (id: string): Model<"google-generative-ai"> =>
}) as Model<"google-generative-ai">;
describe("google-shared convertTools", () => {
it("adds type:object when properties/required exist but type is missing", () => {
it("preserves parameters when type is missing", () => {
const tools = [
{
name: "noType",
@@ -46,12 +46,12 @@ describe("google-shared convertTools", () => {
converted?.[0]?.functionDeclarations?.[0]?.parameters,
);
expect(params.type).toBe("object");
expect(params.type).toBeUndefined();
expect(params.properties).toBeDefined();
expect(params.required).toEqual(["action"]);
});
it("strips unsupported JSON Schema keywords", () => {
it("keeps unsupported JSON Schema keywords intact", () => {
const tools = [
{
name: "example",
@@ -93,11 +93,11 @@ describe("google-shared convertTools", () => {
const list = asRecord(properties.list);
const items = asRecord(list.items);
expect(params).not.toHaveProperty("patternProperties");
expect(params).not.toHaveProperty("additionalProperties");
expect(mode).not.toHaveProperty("const");
expect(options).not.toHaveProperty("anyOf");
expect(items).not.toHaveProperty("const");
expect(params).toHaveProperty("patternProperties");
expect(params).toHaveProperty("additionalProperties");
expect(mode).toHaveProperty("const");
expect(options).toHaveProperty("anyOf");
expect(items).toHaveProperty("const");
expect(params.required).toEqual(["mode"]);
});
@@ -147,7 +147,7 @@ describe("google-shared convertTools", () => {
});
describe("google-shared convertMessages", () => {
it("skips thinking blocks for Gemini to avoid mimicry", () => {
it("keeps thinking blocks when provider/model match", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
@@ -184,7 +184,13 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(0);
expect(contents).toHaveLength(1);
expect(contents[0].role).toBe("model");
expect(contents[0].parts).toHaveLength(1);
expect(contents[0].parts?.[0]).toMatchObject({
thought: true,
thoughtSignature: "sig",
});
});
it("keeps thought signatures for Claude models", () => {
@@ -232,7 +238,7 @@ describe("google-shared convertMessages", () => {
});
});
it("merges consecutive user messages to satisfy Gemini role alternation", () => {
it("does not merge consecutive user messages for Gemini", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
@@ -248,12 +254,12 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(1);
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[0].parts).toHaveLength(2);
expect(contents[1].role).toBe("user");
});
it("merges consecutive user messages for non-Gemini Google models", () => {
it("does not merge consecutive user messages for non-Gemini Google models", () => {
const model = makeModel("claude-3-opus");
const context = {
messages: [
@@ -269,12 +275,12 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(1);
expect(contents).toHaveLength(2);
expect(contents[0].role).toBe("user");
expect(contents[0].parts).toHaveLength(2);
expect(contents[1].role).toBe("user");
});
it("merges consecutive model messages to satisfy Gemini role alternation", () => {
it("does not merge consecutive model messages for Gemini", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
@@ -332,10 +338,10 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(2);
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[1].parts).toHaveLength(2);
expect(contents[2].role).toBe("model");
});
it("handles user message after tool result without model response in between", () => {
@@ -392,10 +398,11 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(3);
expect(contents).toHaveLength(4);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
expect(contents[2].role).toBe("user");
expect(contents[3].role).toBe("user");
const toolResponsePart = contents[2].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionResponse" in part,
@@ -469,10 +476,11 @@ describe("google-shared convertMessages", () => {
} as unknown as Context;
const contents = convertMessages(model, context);
expect(contents).toHaveLength(2);
expect(contents).toHaveLength(3);
expect(contents[0].role).toBe("user");
expect(contents[1].role).toBe("model");
const toolCallPart = contents[1].parts?.find(
expect(contents[2].role).toBe("model");
const toolCallPart = contents[2].parts?.find(
(part) =>
typeof part === "object" && part !== null && "functionCall" in part,
);

View File

@@ -250,6 +250,39 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("treats control commands as mentions for group bypass", async () => {
replyMock.mockResolvedValue({ text: "ok" });
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: "/elevated off",
ts: "123",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("threads replies when incoming message is in a thread", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });

View File

@@ -913,6 +913,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "");
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
if (
isRoom &&
@@ -1058,7 +1059,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? wasMentioned : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,

View File

@@ -486,6 +486,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "");
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && requireMention && canDetectMention) {
if (!wasMentioned && !shouldBypassMention) {
@@ -592,7 +593,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? wasMentioned : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
MediaPath: allMedia[0]?.path,
MediaType: allMedia[0]?.contentType,
MediaUrl: allMedia[0]?.path,

View File

@@ -1132,6 +1132,45 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]");
});
it("sets OriginatingTo to the sender for queued routing", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+15551234567",
to: "+19998887777",
id: "m-originating",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
const payload = resolver.mock.calls[0][0];
expect(payload.OriginatingChannel).toBe("whatsapp");
expect(payload.OriginatingTo).toBe("+15551234567");
expect(payload.To).toBe("+19998887777");
expect(payload.OriginatingTo).not.toBe(payload.To);
});
it("uses per-agent mention patterns for group gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);

View File

@@ -1261,7 +1261,7 @@ export async function monitorWebProvider(
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: msg.to,
OriginatingTo: msg.from,
},
cfg,
dispatcher,