Compare commits

..
46 changed files with 225 additions and 2361 deletions

No files matched your search

+1 -11
View File
@@ -6,34 +6,25 @@
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Commands: add `/commands` list including text-only entries. (#497) — thanks @lc0rp
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- 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
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Doctor: run legacy state migrations in non-interactive mode without prompts.
@@ -124,7 +115,6 @@
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
## 2026.1.8
@@ -331,8 +331,7 @@ actor MacNodeBridgeSession {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
self.logger.warning(
"Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
await self.disconnect()
return
}
@@ -342,8 +341,7 @@ actor MacNodeBridgeSession {
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
self.logger.warning(
"Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect()
return
}
@@ -358,8 +356,7 @@ actor MacNodeBridgeSession {
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
self.logger.warning(
"Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect()
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
+3 -3
View File
@@ -57,7 +57,7 @@ Payload:
"name": "Email",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
"deliver": false,
"provider": "last",
"to": "+15551234567",
"model": "openai/gpt-5.2-mini",
@@ -70,8 +70,8 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
+1 -5
View File
@@ -177,11 +177,7 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
-1
View File
@@ -679,7 +679,6 @@
"platforms/android",
"platforms/windows",
"platforms/linux",
"platforms/hetzner",
"platforms/exe-dev"
]
},
+2 -17
View File
@@ -29,19 +29,6 @@ clawdbot models status
clawdbot doctor
```
Alternative: run the wrapper (also updates Clawdbot config):
```bash
clawdbot models auth setup-token --provider anthropic
```
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
```bash
clawdbot models auth paste-token --provider anthropic
clawdbot models auth paste-token --provider openrouter
```
## Recommended: long‑lived Claude Code token
Run this on the **gateway host** (the machine running the Gateway):
@@ -105,15 +92,13 @@ Use `--agent <id>` to target a specific agent; omit it to use the configured def
2. **Clawdbot** syncs those into
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded.
3. Refreshable OAuth profiles can be refreshed automatically on use. Static
token profiles (including Claude CLI setup-token) are not refreshable by
Clawdbot.
3. OAuth refresh happens automatically on use if a token is expired.
## Troubleshooting
### “No credentials found”
If the Anthropic token profile is missing, run `claude setup-token` on the
If the Anthropic OAuth profile is missing, run `claude setup-token` on the
**gateway host**, then re-check:
```bash
-61
View File
@@ -1464,67 +1464,6 @@ Notes:
- Responses API enables clean reasoning/output separation; WhatsApp sees only final text.
- Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs.
### MiniMax API (platform.minimax.io)
Use MiniMax's Anthropic-compatible API directly without LM Studio:
```json5
{
agent: {
model: { primary: "minimax/MiniMax-M2.1" },
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
"minimax/MiniMax-M2.1": { alias: "Minimax" }
}
},
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
},
{
id: "MiniMax-M2.1-lightning",
name: "MiniMax M2.1 Lightning",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
},
{
id: "MiniMax-M2",
name: "MiniMax M2",
reasoning: true,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
}
}
```
Notes:
- Set `MINIMAX_API_KEY` environment variable or use `clawdbot onboard --auth-choice minimax-api`
- Available models: `MiniMax-M2.1` (default), `MiniMax-M2.1-lightning` (~100 tps), `MiniMax-M2` (reasoning)
- Pricing is a placeholder; MiniMax doesn't publish public rates. Override in `models.json` for accurate cost tracking.
Notes:
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`,
`google-generative-ai`
-2
View File
@@ -51,8 +51,6 @@ It writes config/workspace on the host:
- `~/.clawdbot/`
- `~/clawd`
Running on a VPS? See [Hetzner (Docker VPS)](/platforms/hetzner).
### Manual flow (compose)
```bash
-296
View File
@@ -1,296 +0,0 @@
---
summary: "Run Clawdbot Gateway on Hetzner (Docker + VPS) with durable state and baked-in binaries"
read_when:
- You want a production-grade, always-on Gateway on your own VPS
- You want full control over persistence, binaries, and restart behavior
- You are running Clawdbot in Docker on Hetzner or a similar provider
---
# Clawdbot on Hetzner (Docker, Production VPS Guide)
## Goal
Run a persistent Clawdbot Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
The Gateway can be accessed via:
- SSH port forwarding from your laptop
- Direct port exposure if you manage firewalling and tokens yourself
This guide assumes Ubuntu or Debian on Hetzner.
If you are on another Linux VPS, map packages accordingly.
For the generic Docker flow, see [Docker](/install/docker).
---
## Quick path (experienced operators)
1) Provision Hetzner VPS
2) Install Docker
3) Clone Clawdbot repository
4) Create persistent host directories
5) Configure `.env` and `docker-compose.yml`
6) Bake required binaries into the image
7) `docker compose up -d`
8) Verify persistence and Gateway access
---
## What you need
- Hetzner VPS with root access
- SSH access from your laptop
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials
- WhatsApp QR
- Telegram bot token
- Gmail OAuth
---
## 1) Provision the VPS
Create an Ubuntu or Debian VPS in Hetzner.
Connect as root:
```bash
ssh root@YOUR_VPS_IP
```
This guide assumes the VPS is stateful.
Do not treat it as disposable infrastructure.
---
## 2) Install Docker (on the VPS)
```bash
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sh
```
Verify:
```bash
docker --version
docker compose version
```
---
## 3) Clone the Clawdbot repository
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
```
This guide assumes you will build a custom image to guarantee binary persistence.
---
## 4) Create persistent host directories
Docker containers are ephemeral.
All long-lived state must live on the host.
```bash
mkdir -p /root/.clawdbot
mkdir -p /root/clawd
# Set ownership to the container user (uid 1000):
chown -R 1000:1000 /root/.clawdbot
chown -R 1000:1000 /root/clawd
```
---
## 5) Configure environment variables
Create `.env` in the repository root.
```bash
CLAWDBOT_IMAGE=clawdbot:latest
CLAWDBOT_GATEWAY_TOKEN=change-me-now
CLAWDBOT_GATEWAY_BIND=lan
CLAWDBOT_GATEWAY_PORT=18789
CLAWDBOT_BRIDGE_PORT=18790
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
CLAWDBOT_WORKSPACE_DIR=/root/clawd
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.clawdbot
```
**Do not commit this file.**
---
## 6) Docker Compose configuration
Create or update `docker-compose.yml`.
```yaml
services:
clawdbot-gateway:
image: ${CLAWDBOT_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
- HOME=/home/node
- NODE_ENV=production
- TERM=xterm-256color
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
- CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
ports:
- "${CLAWDBOT_GATEWAY_PORT}:18789"
- "${CLAWDBOT_BRIDGE_PORT}:18790"
- "18793:18793"
command:
[
"node",
"dist/index.js",
"gateway-daemon",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
"${CLAWDBOT_GATEWAY_PORT}"
]
```
---
## 7) Bake required binaries into the image (critical)
Installing binaries inside a running container is a trap.
Anything installed at runtime will be lost on restart.
All external binaries required by skills must be installed at image build time.
The examples below show three common binaries only:
- `gog` for Gmail access
- `goplaces` for Google Places
- `wacli` for WhatsApp
These are examples, not a complete list.
You may install as many binaries as needed using the same pattern.
If you add new skills later that depend on additional binaries, you must:
1. Update the Dockerfile
2. Rebuild the image
3. Restart the containers
**Example Dockerfile**
```dockerfile
FROM node:22-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
# Example binary 1: Gmail CLI
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
# Example binary 2: Google Places CLI
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
# Example binary 3: WhatsApp CLI
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
# Add more binaries below using the same pattern
WORKDIR /app
COPY . .
RUN corepack enable
RUN pnpm install --frozen-lockfile
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
CMD ["node","dist/index.js"]
```
---
## 8) Build and launch
```bash
docker compose build
docker compose up -d clawdbot-gateway
```
Verify binaries:
```bash
docker compose exec clawdbot-gateway which gog
docker compose exec clawdbot-gateway which goplaces
docker compose exec clawdbot-gateway which wacli
```
Expected output:
```
/usr/local/bin/gog
/usr/local/bin/goplaces
/usr/local/bin/wacli
```
---
## 9) Verify Gateway
```bash
docker compose logs -f clawdbot-gateway
```
Success:
```
[gateway] listening on ws://0.0.0.0:18789
```
From your laptop:
```bash
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP
```
Open:
`http://127.0.0.1:18789/`
Paste your gateway token.
---
## What persists where (source of truth)
Clawdbot runs in Docker, but Docker is not the source of truth.
All long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
|---|---|---|---|
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
-5
View File
@@ -20,11 +20,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Windows: [Windows](/platforms/windows)
- Linux: [Linux](/platforms/linux)
## VPS & hosting
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
## Common links
- Install guide: [Getting Started](/start/getting-started)
-1
View File
@@ -36,7 +36,6 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled):
- `/help`
- `/commands`
- `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
+2 -6
View File
@@ -80,7 +80,6 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "vitest run --config vitest.live.config.ts",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
@@ -94,7 +93,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@buape/carbon": "0.0.0-beta-20260109165250",
"@buape/carbon": "0.13.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
@@ -170,9 +169,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch"
}
},
"vitest": {
@@ -210,7 +207,6 @@
"patchedDependencies": {
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"qrcode-terminal": "patches/qrcode-terminal.patch",
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
}
-17
View File
@@ -1,17 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 0000000..1111111 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,12 @@ async function* streamGeminiCli(model, context, credentials, options) {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // PATCH: Fail immediately on 429 for Antigravity to let caller rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && response.status === 429) {
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
@@ -26,23 +26,3 @@ index 0000000..1111111 100644
}
/** Result from createAgentSession */
export interface CreateAgentSessionResult {
diff --git a/dist/core/session-manager.js b/dist/core/session-manager.js
index b2aba5280d002253b0938b75aedbb9e6e6c4dcf8..67464efff535dbd7a8e6ed825aab2b305ca2aee2 100644
--- a/dist/core/session-manager.js
+++ b/dist/core/session-manager.js
@@ -161,6 +161,15 @@ export function buildSessionContext(entries, leafId, byId) {
const messages = [];
const appendMessage = (entry) => {
if (entry.type === "message") {
+ // PATCH: Filter out empty error assistant messages to prevent session corruption
+ // When 429/500 errors occur during tool execution, empty error messages get persisted
+ // to the session file. These break the tool_use -> tool_result chain for Claude/Gemini.
+ const msg = entry.message;
+ if (msg.role === "assistant" &&
+ msg.stopReason === "error" &&
+ (!msg.content || msg.content.length === 0)) {
+ return; // Skip empty error messages
+ }
messages.push(entry.message);
}
else if (entry.type === "custom_message") {
+61 -87
View File
@@ -11,20 +11,14 @@ patchedDependencies:
'@mariozechner/pi-agent-core':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch
'@mariozechner/pi-ai':
hash: 3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent':
hash: 58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e
path: patches/@mariozechner__pi-coding-agent.patch
importers:
.:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260109165250
version: 0.0.0-beta-20260109165250(hono@4.11.3)
specifier: 0.13.0
version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -42,10 +36,10 @@ importers:
version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.41.0
version: 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.41.0
version: 0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.41.0
version: 0.41.0
@@ -372,8 +366,8 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.0.0-beta-20260109165250':
resolution: {integrity: sha512-Xt8W0RYZxaprp2YxMU1rZRfGkHQWEJ2KZUurMwK/G4WVTfJSWAUeaeOGhpO40fCm/g0iPMuSLgQ4eudGOQHigw==}
'@buape/carbon@0.13.0':
resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -391,12 +385,8 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@cloudflare/workers-types@4.20251205.0':
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
'@cloudflare/workers-types@4.20250513.0':
resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -603,8 +593,8 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true
'@hono/node-server@1.19.6':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
'@hono/node-server@1.18.2':
resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -1290,8 +1280,8 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.3.3':
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
'@types/bun@1.2.23':
resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1347,8 +1337,8 @@ packages:
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@24.10.4':
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
@@ -1365,6 +1355,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -1618,8 +1611,10 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bun-types@1.3.3:
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
bun-types@1.2.23:
resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
peerDependencies:
'@types/react': ^19
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@@ -1731,6 +1726,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -1770,8 +1768,8 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
discord-api-types@0.38.36:
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
discord-api-types@0.38.29:
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
@@ -2674,23 +2672,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -3068,6 +3049,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -3374,24 +3358,20 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260109165250(hono@4.11.3)':
'@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
dependencies:
'@types/node': 24.10.4
discord-api-types: 0.38.36
'@types/node': 22.19.3
discord-api-types: 0.38.29
optionalDependencies:
'@cloudflare/workers-types': 4.20251205.0
'@discordjs/voice': 0.19.0
'@hono/node-server': 1.19.6(hono@4.11.3)
'@types/bun': 1.3.3
'@cloudflare/workers-types': 4.20250513.0
'@hono/node-server': 1.18.2(hono@4.11.3)
'@types/bun': 1.2.23(@types/react@19.2.7)
'@types/ws': 8.18.1
ws: 8.18.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@types/react'
- bufferutil
- ffmpeg-static
- hono
- node-opus
- opusscript
- utf-8-validate
'@cacheable/memory@2.0.7':
@@ -3423,23 +3403,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@cloudflare/workers-types@4.20251205.0':
optional: true
'@discordjs/voice@0.19.0':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.37
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@cloudflare/workers-types@4.20250513.0':
optional: true
'@emnapi/core@1.8.1':
@@ -3577,7 +3541,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.19.6(hono@4.11.3)':
'@hono/node-server@1.18.2(hono@4.11.3)':
dependencies:
hono: 4.11.3
optional: true
@@ -3783,7 +3747,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -3793,7 +3757,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-ai@0.41.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
@@ -3813,11 +3777,11 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.41.0(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0
chalk: 5.6.2
cli-highlight: 2.1.11
@@ -4215,9 +4179,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.0.3
'@types/bun@1.3.3':
'@types/bun@1.2.23(@types/react@19.2.7)':
dependencies:
bun-types: 1.3.3
bun-types: 1.2.23(@types/react@19.2.7)
transitivePeerDependencies:
- '@types/react'
optional: true
'@types/chai@5.2.3':
@@ -4286,9 +4252,9 @@ snapshots:
'@types/node@10.17.60': {}
'@types/node@24.10.4':
'@types/node@22.19.3':
dependencies:
undici-types: 7.16.0
undici-types: 6.21.0
'@types/node@25.0.3':
dependencies:
@@ -4304,6 +4270,11 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
optional: true
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@@ -4637,9 +4608,10 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bun-types@1.3.3:
bun-types@1.2.23(@types/react@19.2.7):
dependencies:
'@types/node': 25.0.3
'@types/react': 19.2.7
optional: true
bytes@3.1.2: {}
@@ -4757,6 +4729,9 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
csstype@3.2.3:
optional: true
curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {}
@@ -4778,7 +4753,7 @@ snapshots:
diff@8.0.2: {}
discord-api-types@0.38.36: {}
discord-api-types@0.38.29: {}
discord-api-types@0.38.37: {}
@@ -5705,9 +5680,6 @@ snapshots:
react-is: 17.0.2
optional: true
prism-media@1.3.5:
optional: true
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
@@ -6182,6 +6154,8 @@ snapshots:
uint8array-extras@1.5.0: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.18.2: {}
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_NAME="clawdbot-doctor-install-switch-e2e"
echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running doctor install switch E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
export PATH="/tmp/clawdbot-bin:$PATH"
mkdir -p /tmp/clawdbot-bin
cat > /tmp/clawdbot-bin/systemctl <<"SYSTEMCTL"
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
if [[ "${args[0]:-}" == "--user" ]]; then
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
case "$cmd" in
status)
exit 0
;;
is-enabled)
unit="${args[1]:-}"
unit_path="$HOME/.config/systemd/user/${unit}"
if [ -f "$unit_path" ]; then
exit 0
fi
exit 1
;;
show)
echo "ActiveState=inactive"
echo "SubState=dead"
echo "MainPID=0"
echo "ExecMainStatus=0"
echo "ExecMainCode=0"
exit 0
;;
*)
exit 0
;;
esac
SYSTEMCTL
chmod +x /tmp/clawdbot-bin/systemctl
cat > /tmp/clawdbot-bin/loginctl <<"LOGINCTL"
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == *"show-user"* ]]; then
echo "Linger=yes"
exit 0
fi
if [[ "$*" == *"enable-linger"* ]]; then
exit 0
fi
exit 0
LOGINCTL
chmod +x /tmp/clawdbot-bin/loginctl
# Install the npm-global variant from the local /app source.
npm install -g --prefix /tmp/npm-prefix /app
npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js"
assert_entrypoint() {
local unit_path="$1"
local expected="$2"
local exec_line=""
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path"
exit 1
fi
exec_line="${exec_line#ExecStart=}"
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
if [ "$entrypoint" != "$expected" ]; then
echo "Expected entrypoint $expected, got $entrypoint"
exit 1
fi
}
# Each flow: install service with one variant, run doctor from the other,
# and verify ExecStart entrypoint switches accordingly.
run_flow() {
local name="$1"
local install_cmd="$2"
local install_expected="$3"
local doctor_cmd="$4"
local doctor_expected="$5"
echo "== Flow: $name =="
home_dir=$(mktemp -d "/tmp/clawdbot-switch-${name}.XXXXXX")
export HOME="$home_dir"
export USER="testuser"
eval "$install_cmd"
unit_path="$HOME/.config/systemd/user/clawdbot-gateway.service"
if [ ! -f "$unit_path" ]; then
echo "Missing unit file: $unit_path"
exit 1
fi
assert_entrypoint "$unit_path" "$install_expected"
eval "$doctor_cmd"
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"node $git_entry doctor --repair" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_entry daemon install --force" \
"$git_entry" \
"$npm_bin doctor --repair" \
"$npm_entry"
'
+5 -6
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
@@ -11,16 +11,15 @@ const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
describeLive("minimax live", () => {
it("returns assistant text", async () => {
const model: Model<"anthropic-messages"> = {
const model: Model<"openai-completions"> = {
id: MINIMAX_MODEL,
name: `MiniMax ${MINIMAX_MODEL}`,
api: "anthropic-messages",
api: "openai-completions",
provider: "minimax",
baseUrl: MINIMAX_BASE_URL,
reasoning: MINIMAX_MODEL === "MiniMax-M2",
reasoning: false,
input: ["text"],
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display)
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
};
-165
View File
@@ -2,10 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
@@ -13,28 +10,17 @@ import {
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../discord/send.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
@@ -221,157 +207,6 @@ export async function handleDiscordGuildAction(
const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId = readParentIdParam(params);
const nsfw = params.nsfw as boolean | undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
const channel = await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
await moveChannelDiscord({
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
});
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
const targetTypeRaw = readStringParam(params, "targetType", {
required: true,
});
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
await removeChannelPermissionDiscord(channelId, targetId);
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
}
-233
View File
@@ -1,58 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
}));
const createThreadDiscord = vi.fn(async () => ({}));
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
const deleteMessageDiscord = vi.fn(async () => ({}));
const editChannelDiscord = vi.fn(async () => ({
id: "C1",
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
const pinMessageDiscord = vi.fn(async () => ({}));
const reactMessageDiscord = vi.fn(async () => ({}));
const readMessagesDiscord = vi.fn(async () => []);
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) =>
fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) =>
removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) =>
removeOwnReactionsDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
@@ -60,8 +40,6 @@ vi.mock("../../discord/send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) =>
setChannelPermissionDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
}));
@@ -139,214 +117,3 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;
describe("handleDiscordGuildAction - channel management", () => {
it("creates a channel", async () => {
const result = await handleDiscordGuildAction(
"channelCreate",
{
guildId: "G1",
name: "test-channel",
type: 0,
topic: "Test topic",
},
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "test-channel",
type: 0,
parentId: undefined,
topic: "Test topic",
position: undefined,
nsfw: undefined,
});
expect(result.details).toMatchObject({ ok: true });
});
it("respects channel gating for channelCreate", async () => {
await expect(
handleDiscordGuildAction(
"channelCreate",
{ guildId: "G1", name: "test" },
channelsDisabled,
),
).rejects.toThrow(/Discord channel management is disabled/);
});
it("edits a channel", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
name: "new-name",
topic: "new topic",
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: "new-name",
topic: "new topic",
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("clears the channel parent when parentId is null", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
{ channelId: "C1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
});
it("moves a channel", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
});
});
it("clears the channel parent on move when parentId is null", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => {
await handleDiscordGuildAction(
"categoryCreate",
{ guildId: "G1", name: "My Category" },
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "My Category",
type: 4,
position: undefined,
});
});
it("edits a category", async () => {
await handleDiscordGuildAction(
"categoryEdit",
{ categoryId: "CAT1", name: "Renamed Category" },
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "CAT1",
name: "Renamed Category",
position: undefined,
});
});
it("deletes a category", async () => {
await handleDiscordGuildAction(
"categoryDelete",
{ categoryId: "CAT1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
});
it("sets channel permissions for role", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "R1",
targetType: "role",
allow: "1024",
deny: "2048",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "R1",
targetType: 0,
allow: "1024",
deny: "2048",
});
});
it("sets channel permissions for member", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "U1",
targetType: "member",
allow: "1024",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "U1",
targetType: 1,
allow: "1024",
deny: undefined,
});
});
it("removes channel permissions", async () => {
await handleDiscordGuildAction(
"channelPermissionRemove",
{ channelId: "C1", targetId: "R1" },
channelsEnabled,
);
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
});
});
-9
View File
@@ -37,15 +37,6 @@ const guildActions = new Set([
"voiceStatus",
"eventList",
"eventCreate",
"channelCreate",
"channelEdit",
"channelDelete",
"channelMove",
"categoryCreate",
"categoryEdit",
"categoryDelete",
"channelPermissionSet",
"channelPermissionRemove",
]);
const moderationActions = new Set(["timeout", "kick", "ban"]);
-63
View File
@@ -202,67 +202,4 @@ export const DiscordToolSchema = Type.Union([
reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()),
}),
// Channel management actions
Type.Object({
action: Type.Literal("channelCreate"),
guildId: Type.String(),
name: Type.String(),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("channelEdit"),
channelId: Type.String(),
name: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("channelDelete"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelMove"),
guildId: Type.String(),
channelId: Type.String(),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
position: Type.Optional(Type.Number()),
}),
// Category management actions (convenience aliases)
Type.Object({
action: Type.Literal("categoryCreate"),
guildId: Type.String(),
name: Type.String(),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryEdit"),
categoryId: Type.String(),
name: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryDelete"),
categoryId: Type.String(),
}),
// Permission overwrite actions
Type.Object({
action: Type.Literal("channelPermissionSet"),
channelId: Type.String(),
targetId: Type.String(),
targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]),
allow: Type.Optional(Type.String()),
deny: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("channelPermissionRemove"),
channelId: Type.String(),
targetId: Type.String(),
}),
]);
+4 -2
View File
@@ -43,11 +43,13 @@ describe("control command parsing", () => {
expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
expect(hasControlCommand("/compact")).toBe(true);
expect(hasControlCommand("/compact:")).toBe(true);
expect(hasControlCommand("compact")).toBe(false);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
+1 -1
View File
@@ -18,11 +18,11 @@ describe("commands registry", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
for (const command of listChatCommands()) {
+12 -105
View File
@@ -17,11 +17,6 @@ export type NativeCommandSpec = {
acceptsArgs: boolean;
};
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
function defineChatCommand(command: {
key: string;
nativeName?: string;
@@ -31,11 +26,8 @@ function defineChatCommand(command: {
textAliases?: string[];
scope?: CommandScope;
}): ChatCommandDefinition {
const aliases = (
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
)
.map((alias) => alias.trim())
.filter(Boolean);
const aliases =
command.textAliases ?? (command.textAlias ? [command.textAlias] : []);
const scope =
command.scope ??
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
@@ -58,64 +50,16 @@ function registerAlias(
if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`);
}
const existing = new Set(
command.textAliases.map((alias) => alias.trim().toLowerCase()),
);
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
for (const alias of aliases) {
const trimmed = alias.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (existing.has(lowered)) continue;
existing.add(lowered);
if (existing.has(trimmed)) continue;
existing.add(trimmed);
command.textAliases.push(trimmed);
}
}
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
const keys = new Set<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
for (const command of commands) {
if (keys.has(command.key)) {
throw new Error(`Duplicate command key: ${command.key}`);
}
keys.add(command.key);
const nativeName = command.nativeName?.trim();
if (command.scope === "text") {
if (nativeName) {
throw new Error(`Text-only command has native name: ${command.key}`);
}
if (command.textAliases.length === 0) {
throw new Error(`Text-only command missing text alias: ${command.key}`);
}
} else if (!nativeName) {
throw new Error(`Native command missing native name: ${command.key}`);
} else {
const nativeKey = nativeName.toLowerCase();
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${nativeName}`);
}
nativeNames.add(nativeKey);
}
if (command.scope === "native" && command.textAliases.length > 0) {
throw new Error(`Native-only command has text aliases: ${command.key}`);
}
for (const alias of command.textAliases) {
if (!alias.startsWith("/")) {
throw new Error(`Command alias missing leading '/': ${alias}`);
}
const aliasKey = alias.toLowerCase();
if (textAliases.has(aliasKey)) {
throw new Error(`Duplicate command alias: ${alias}`);
}
textAliases.add(aliasKey);
}
}
}
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const commands: ChatCommandDefinition[] = [
defineChatCommand({
@@ -246,28 +190,11 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
assertCommandRegistry(commands);
return commands;
})();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection:
| {
exact: Set<string>;
@@ -299,8 +226,8 @@ export function findCommandByNativeName(
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) =>
command.scope !== "text" &&
command.nativeName?.toLowerCase() === normalized,
command.nativeName?.toLowerCase() === normalized &&
command.scope !== "text",
);
}
@@ -312,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
+1 -22
View File
@@ -240,23 +240,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
)
.option(
"--token <token>",
"Token value (non-interactive; used with --auth-choice token)",
)
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",
)
.option(
"--token-expires-in <duration>",
"Optional token expiry duration (e.g. 365d, 12h)",
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key")
@@ -286,7 +270,6 @@ export function buildProgram() {
mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -299,10 +282,6 @@ export function buildProgram() {
| "minimax"
| "skip"
| undefined,
tokenProvider: opts.tokenProvider as string | undefined,
token: opts.token as string | undefined,
tokenProfileId: opts.tokenProfileId as string | undefined,
tokenExpiresIn: opts.tokenExpiresIn as string | undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
+2 -12
View File
@@ -64,23 +64,17 @@ export function buildAuthChoiceOptions(params: {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude CLI)",
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude CLI)",
label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access",
});
}
options.push({
value: "setup-token",
label: "Anthropic token (run setup-token)",
hint: "Runs `claude setup-token`",
});
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
@@ -101,10 +95,6 @@ export function buildAuthChoiceOptions(params: {
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
options.push({
value: "minimax-api",
label: "MiniMax API (platform.minimax.io)",
});
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
}
+1 -82
View File
@@ -36,8 +36,6 @@ import {
} from "./google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
applyMinimaxHostedProviderConfig,
@@ -218,68 +216,7 @@ export async function applyAuthChoice(params: {
provider: "anthropic",
mode: "token",
});
} else if (
params.authChoice === "setup-token" ||
params.authChoice === "oauth"
) {
await params.prompter.note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const proceed = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig, agentModelOverride };
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
if (typeof res.status === "number" && res.status !== 0) {
await params.prompter.note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (params.authChoice === "token") {
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
@@ -631,24 +568,6 @@ export async function applyAuthChoice(params: {
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
await noteAgentModel("lmstudio/minimax-m2.1-gs32");
}
} else if (params.authChoice === "minimax-api") {
const key = await params.prompter.text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setMinimaxApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyMinimaxApiConfig(nextConfig);
} else {
nextConfig = applyMinimaxApiProviderConfig(nextConfig);
agentModelOverride = "minimax/MiniMax-M2.1";
await noteAgentModel("minimax/MiniMax-M2.1");
}
}
return { config: nextConfig, agentModelOverride };
+1 -64
View File
@@ -352,7 +352,6 @@ async function promptAuthConfig(
runtime,
) as
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -404,68 +403,7 @@ async function promptAuthConfig(
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "setup-token" || authChoice === "oauth") {
note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return next;
}
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (!runNow) return next;
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return next;
}
if (typeof res.status === "number" && res.status !== 0) {
note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return next;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return next;
}
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "token") {
} else if (authChoice === "token" || authChoice === "oauth") {
const provider = guardCancel(
await select({
message: "Token provider",
@@ -788,7 +726,6 @@ async function promptAuthConfig(
: (next.agents?.defaults?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "setup-token" ||
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "apiKey";
+24 -51
View File
@@ -22,7 +22,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import {
auditGatewayServiceConfig,
needsNodeRuntimeMigration,
SERVICE_AUDIT_CODES,
} from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -49,17 +48,6 @@ function detectGatewayRuntime(
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
function findGatewayEntrypoint(programArguments?: string[]): string | null {
if (!programArguments || programArguments.length === 0) return null;
const gatewayIndex = programArguments.indexOf("gateway");
if (gatewayIndex <= 0) return null;
return programArguments[gatewayIndex - 1] ?? null;
}
function normalizeExecutablePath(value: string): string {
return path.resolve(value);
}
export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
mode: "local" | "remote",
@@ -183,45 +171,6 @@ export async function maybeRepairGatewayServiceConfig(
env: process.env,
command,
});
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
if (
expectedEntrypoint &&
currentEntrypoint &&
normalizeExecutablePath(expectedEntrypoint) !==
normalizeExecutablePath(currentEntrypoint)
) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
message: "Gateway service entrypoint does not match the current install.",
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
level: "recommended",
});
}
if (audit.issues.length === 0) return;
note(
@@ -258,6 +207,30 @@ export async function maybeRepairGatewayServiceConfig(
initialValue: true,
});
if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
-145
View File
@@ -7,8 +7,6 @@ import { afterEach, describe, expect, it } from "vitest";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -107,146 +105,3 @@ describe("applyAuthProfileConfig", () => {
]);
});
});
describe("applyMinimaxApiConfig", () => {
it("adds minimax provider with correct settings", () => {
const cfg = applyMinimaxApiConfig({});
expect(cfg.models?.providers?.minimax).toMatchObject({
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
});
});
it("sets correct primary model", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1-lightning");
expect(cfg.agents?.defaults?.model?.primary).toBe(
"minimax/MiniMax-M2.1-lightning",
);
});
it("sets reasoning flag for MiniMax-M2 model", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2");
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
});
it("does not set reasoning for non-M2 models", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(false);
});
it("preserves existing model fallbacks", () => {
const cfg = applyMinimaxApiConfig({
agents: {
defaults: {
model: { fallbacks: ["anthropic/claude-opus-4-5"] },
},
},
});
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
});
it("adds model alias", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"]?.alias).toBe(
"Minimax",
);
});
it("preserves existing model params when adding alias", () => {
const cfg = applyMinimaxApiConfig(
{
agents: {
defaults: {
models: {
"minimax/MiniMax-M2.1": {
alias: "MiniMax",
params: { custom: "value" },
},
},
},
},
},
"MiniMax-M2.1",
);
expect(
cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"],
).toMatchObject({ alias: "Minimax", params: { custom: "value" } });
});
it("replaces existing minimax provider entirely", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
minimax: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.minimax?.models[0]?.id).toBe("MiniMax-M2.1");
});
it("preserves other providers when adding minimax", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic-key",
api: "anthropic-messages",
models: [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
});
expect(cfg.models?.providers?.anthropic).toBeDefined();
expect(cfg.models?.providers?.minimax).toBeDefined();
});
it("preserves existing models mode", () => {
const cfg = applyMinimaxApiConfig({
models: { mode: "replace", providers: {} },
});
expect(cfg.models?.mode).toBe("replace");
});
});
describe("applyMinimaxApiProviderConfig", () => {
it("does not overwrite existing primary model", () => {
const cfg = applyMinimaxApiProviderConfig({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
});
expect(cfg.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
});
});
-70
View File
@@ -263,73 +263,3 @@ export function applyMinimaxHostedConfig(
},
};
}
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
export function applyMinimaxApiProviderConfig(
cfg: ClawdbotConfig,
modelId: string = "MiniMax-M2.1",
): ClawdbotConfig {
const providers = { ...cfg.models?.providers };
providers.minimax = {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "", // Resolved via MINIMAX_API_KEY env var or auth profile
api: "anthropic-messages",
models: [
{
id: modelId,
name: `MiniMax ${modelId}`,
reasoning: modelId === "MiniMax-M2",
input: ["text"],
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
const models = { ...cfg.agents?.defaults?.models };
models[`minimax/${modelId}`] = {
...models[`minimax/${modelId}`],
alias: "Minimax",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: { mode: cfg.models?.mode ?? "merge", providers },
};
}
export function applyMinimaxApiConfig(
cfg: ClawdbotConfig,
modelId: string = "MiniMax-M2.1",
): ClawdbotConfig {
const next = applyMinimaxApiProviderConfig(cfg, modelId);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (
next.agents.defaults.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: `minimax/${modelId}`,
},
},
},
};
}
-16
View File
@@ -29,7 +29,6 @@ import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js"
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
setAnthropicApiKey,
@@ -174,21 +173,6 @@ export async function runNonInteractiveOnboarding(
mode: "api_key",
});
nextConfig = applyMinimaxHostedConfig(nextConfig);
} else if (authChoice === "minimax-api") {
const key =
opts.minimaxApiKey?.trim() || resolveEnvApiKey("minimax")?.apiKey;
if (!key) {
runtime.error("Missing --minimax-api-key (or MINIMAX_API_KEY in env).");
runtime.exit(1);
return;
}
await setMinimaxApiKey(key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
nextConfig = applyMinimaxApiConfig(nextConfig);
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
-11
View File
@@ -3,9 +3,7 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote";
export type AuthChoice =
// Legacy alias for `setup-token` (kept for backwards CLI compatibility).
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -16,7 +14,6 @@ export type AuthChoice =
| "gemini-api-key"
| "minimax-cloud"
| "minimax"
| "minimax-api"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
@@ -30,14 +27,6 @@ export type OnboardOptions = {
workspace?: string;
nonInteractive?: boolean;
authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProvider?: string;
/** Used when `authChoice=token` in non-interactive mode. */
token?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProfileId?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenExpiresIn?: string;
anthropicApiKey?: string;
openaiApiKey?: string;
geminiApiKey?: string;
+3 -8
View File
@@ -10,11 +10,6 @@ export async function onboardCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
assertSupportedRuntime(runtime);
const authChoice =
opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedOpts =
authChoice === opts.authChoice ? opts : { ...opts, authChoice };
if (process.platform === "win32") {
runtime.log(
[
@@ -25,12 +20,12 @@ export async function onboardCommand(
);
}
if (normalizedOpts.nonInteractive) {
await runNonInteractiveOnboarding(normalizedOpts, runtime);
if (opts.nonInteractive) {
await runNonInteractiveOnboarding(opts, runtime);
return;
}
await runInteractiveOnboarding(normalizedOpts, runtime);
await runInteractiveOnboarding(opts, runtime);
}
export type { OnboardOptions } from "./onboard-types.js";
-1
View File
@@ -404,7 +404,6 @@ export type DiscordActionConfig = {
moderation?: boolean;
emojiUploads?: boolean;
stickerUploads?: boolean;
channels?: boolean;
};
export type DiscordAccountConfig = {
-1
View File
@@ -30,7 +30,6 @@ export type ServiceConfigAudit = {
export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing",
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal",
-267
View File
@@ -1,6 +1,5 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
@@ -120,168 +119,6 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000);
it("caches channel info lookups between messages", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "open" } },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
const fetchChannel = vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
});
const client = { fetchChannel } as unknown as Client;
const baseMessage = {
content: "hello",
channelId: "cache-channel-1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u-cache", bot: false, username: "Ada" },
};
await handler(
{
message: { ...baseMessage, id: "m-cache-1" },
author: baseMessage.author,
guild_id: null,
},
client,
);
await handler(
{
message: { ...baseMessage, id: "m-cache-2" },
author: baseMessage.author,
guild_id: null,
},
client,
);
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("uses channel id allowlists for non-thread channels with categories", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx: { SessionKey?: string } | undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: {
"*": {
requireMention: false,
channels: { c1: { allow: true } },
},
},
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } },
},
});
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
parentId: "category-1",
}),
rest: { get: vi.fn() },
} as unknown as Client;
await handler(
{
message: {
id: "m-category",
content: "hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
},
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
});
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
@@ -544,110 +381,6 @@ describe("discord tool result dispatch", () => {
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
it("treats forum threads as distinct sessions without channel payloads", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx:
| {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
}
| undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: false } },
});
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({
type: ChannelType.PublicThread,
name: "topic-1",
parentId: "forum-1",
})
.mockResolvedValueOnce({
type: ChannelType.GuildForum,
name: "support",
});
const restGet = vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
});
const client = {
fetchChannel,
rest: {
get: restGet,
},
} as unknown as Client;
await handler(
{
message: {
id: "m6",
content: "thread reply",
channelId: "t1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
},
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
member: { displayName: "Bob" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe(
"agent:main:discord:channel:forum-1",
);
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support");
expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1"));
});
it("scopes thread sessions to the routed agent", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
+16 -132
View File
@@ -113,20 +113,7 @@ type DiscordThreadStarter = {
timestamp?: number;
};
type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
function logSlowDiscordListener(params: {
@@ -152,22 +139,14 @@ async function resolveDiscordThreadStarter(params: {
channel: DiscordThreadChannel;
client: Client;
parentId?: string;
parentType?: ChannelType;
}): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached;
try {
const parentType = params.parentType;
const isForumParent =
parentType === ChannelType.GuildForum ||
parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent
? params.channel.id
: params.parentId;
if (!messageChannelId) return null;
if (!params.parentId) return null;
const starter = (await params.client.rest.get(
Routes.channelMessage(messageChannelId, params.channel.id),
Routes.channelMessage(params.parentId, params.channel.id),
)) as {
content?: string | null;
embeds?: Array<{ description?: string | null }>;
@@ -247,66 +226,6 @@ export type DiscordMessageHandler = (
client: Client,
) => Promise<void>;
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
type DiscordThreadParentInfo = {
id?: string;
name?: string;
type?: ChannelType;
};
function resolveDiscordThreadChannel(params: {
isGuildMessage: boolean;
message: DiscordMessageEvent["message"];
channelInfo: DiscordChannelInfo | null;
}): DiscordThreadChannel | null {
if (!params.isGuildMessage) return null;
const { message, channelInfo } = params;
const channel =
"channel" in message
? (message as { channel?: unknown }).channel
: undefined;
const isThreadChannel =
channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread();
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
if (!isDiscordThreadType(channelInfo?.type)) return null;
return {
id: message.channelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
};
}
async function resolveDiscordThreadParentInfo(params: {
client: Client;
threadChannel: DiscordThreadChannel;
channelInfo: DiscordChannelInfo | null;
}): Promise<DiscordThreadParentInfo> {
const { threadChannel, channelInfo, client } = params;
const parentId =
threadChannel.parentId ??
threadChannel.parent?.id ??
channelInfo?.parentId ??
undefined;
if (!parentId) return {};
let parentName = threadChannel.parent?.name;
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;
return { id: parentId, name: parentName, type: parentType };
}
export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode;
replyToId?: string;
@@ -742,24 +661,17 @@ export function createDiscordMessageHandler(params: {
"name" in message.channel
? message.channel.name
: undefined);
const threadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
});
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentType: ChannelType | undefined;
if (threadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client,
threadChannel,
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentType = parentInfo.type;
}
const isThreadChannel =
isGuildMessage &&
message.channel &&
"isThread" in message.channel &&
message.channel.isThread();
const threadChannel = isThreadChannel
? (message.channel as DiscordThreadChannel)
: null;
const threadParentId =
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
const threadParentName = threadChannel?.parent?.name;
const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName
@@ -1023,7 +935,6 @@ export function createDiscordMessageHandler(params: {
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
});
if (starter?.text) {
const starterEnvelope = formatThreadStarterEnvelope({
@@ -1773,42 +1684,15 @@ async function deliverDiscordReply(params: {
async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
if (!channel) return null;
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId =
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
return { type: channel.type, name, topic };
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
}
-125
View File
@@ -183,41 +183,6 @@ export type DiscordStickerUpload = {
mediaUrl: string;
};
export type DiscordChannelCreate = {
guildId: string;
name: string;
type?: number;
parentId?: string;
topic?: string;
position?: number;
nsfw?: boolean;
};
export type DiscordChannelEdit = {
channelId: string;
name?: string;
topic?: string;
position?: number;
parentId?: string | null;
nsfw?: boolean;
rateLimitPerUser?: number;
};
export type DiscordChannelMove = {
guildId: string;
channelId: string;
parentId?: string | null;
position?: number;
};
export type DiscordChannelPermissionSet = {
channelId: string;
targetId: string;
targetType: 0 | 1;
allow?: string;
deny?: string;
};
function resolveToken(params: {
explicit?: string;
accountId: string;
@@ -1226,93 +1191,3 @@ export async function banMemberDiscord(
});
return { ok: true };
}
// Channel management functions
export async function createChannelDiscord(
payload: DiscordChannelCreate,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
name: payload.name,
};
if (payload.type !== undefined) body.type = payload.type;
if (payload.parentId) body.parent_id = payload.parentId;
if (payload.topic) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
return (await rest.post(Routes.guildChannels(payload.guildId), {
body,
})) as APIChannel;
}
export async function editChannelDiscord(
payload: DiscordChannelEdit,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.topic !== undefined) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.parentId !== undefined) body.parent_id = payload.parentId;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
if (payload.rateLimitPerUser !== undefined)
body.rate_limit_per_user = payload.rateLimitPerUser;
return (await rest.patch(Routes.channel(payload.channelId), {
body,
})) as APIChannel;
}
export async function deleteChannelDiscord(
channelId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channel(channelId));
return { ok: true, channelId };
}
export async function moveChannelDiscord(
payload: DiscordChannelMove,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Array<Record<string, unknown>> = [
{
id: payload.channelId,
...(payload.parentId !== undefined && { parent_id: payload.parentId }),
...(payload.position !== undefined && { position: payload.position }),
},
];
await rest.patch(Routes.guildChannels(payload.guildId), { body });
return { ok: true };
}
export async function setChannelPermissionDiscord(
payload: DiscordChannelPermissionSet,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
type: payload.targetType,
};
if (payload.allow !== undefined) body.allow = payload.allow;
if (payload.deny !== undefined) body.deny = payload.deny;
await rest.put(
`/channels/${payload.channelId}/permissions/${payload.targetId}`,
{ body },
);
return { ok: true };
}
export async function removeChannelPermissionDiscord(
channelId: string,
targetId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(`/channels/${channelId}/permissions/${targetId}`);
return { ok: true };
}
+26 -4
View File
@@ -6,7 +6,6 @@ import {
type HookMappingConfig,
type HooksConfig,
} from "../config/config.js";
import type { HookMessageProvider } from "./hooks.js";
export type HookMappingResolved = {
id: string;
@@ -19,7 +18,15 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
provider?: HookMessageProvider;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -52,7 +59,15 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
provider?: HookMessageProvider;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -90,7 +105,14 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to: string;
model: string;
thinking: string;
-19
View File
@@ -66,16 +66,6 @@ describe("gateway hooks helpers", () => {
expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.provider).toBe("last");
expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
const explicitNoDeliver = normalizeAgentPayload(
{ message: "hello", deliver: false },
{ idFactory: () => "fixed" },
);
expect(explicitNoDeliver.ok).toBe(true);
if (explicitNoDeliver.ok) {
expect(explicitNoDeliver.value.deliver).toBe(false);
}
const imsg = normalizeAgentPayload(
@@ -87,15 +77,6 @@ describe("gateway hooks helpers", () => {
expect(imsg.value.provider).toBe("imessage");
}
const teams = normalizeAgentPayload(
{ message: "yo", provider: "teams" },
{ idFactory: () => "x" },
);
expect(teams.ok).toBe(true);
if (teams.ok) {
expect(teams.value.provider).toBe("msteams");
}
const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
expect(bad.ok).toBe(false);
});
+33 -32
View File
@@ -138,41 +138,20 @@ export type HookAgentPayload = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
};
const HOOK_PROVIDER_VALUES = [
"last",
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
export type HookMessageProvider = (typeof HOOK_PROVIDER_VALUES)[number];
const hookProviderSet = new Set<string>(HOOK_PROVIDER_VALUES);
export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`;
export function resolveHookProvider(raw: unknown): HookMessageProvider | null {
if (raw === undefined) return "last";
if (typeof raw !== "string") return null;
const normalized = normalizeMessageProvider(raw);
if (!normalized || !hookProviderSet.has(normalized)) return null;
return normalized as HookMessageProvider;
}
export function resolveHookDeliver(raw: unknown): boolean {
return raw !== false;
}
export function normalizeAgentPayload(
payload: Record<string, unknown>,
opts?: { idFactory?: () => string },
@@ -196,8 +175,30 @@ export function normalizeAgentPayload(
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim()
: `hook:${idFactory()}`;
const provider = resolveHookProvider(payload.provider);
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR };
const providerRaw = payload.provider;
const providerNormalized =
typeof providerRaw === "string"
? normalizeMessageProvider(providerRaw)
: undefined;
const provider =
providerNormalized === "whatsapp" ||
providerNormalized === "telegram" ||
providerNormalized === "discord" ||
providerNormalized === "slack" ||
providerNormalized === "signal" ||
providerNormalized === "imessage" ||
providerNormalized === "last"
? providerNormalized
: providerRaw === undefined
? "last"
: null;
if (provider === null) {
return {
ok: false,
error:
"provider must be last|whatsapp|telegram|discord|slack|signal|imessage",
};
}
const toRaw = payload.to;
const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
@@ -209,7 +210,7 @@ export function normalizeAgentPayload(
if (modelRaw !== undefined && !model) {
return { ok: false, error: "model required" };
}
const deliver = resolveHookDeliver(payload.deliver);
const deliver = payload.deliver === true;
const thinkingRaw = payload.thinking;
const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim()
+11 -12
View File
@@ -11,15 +11,11 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
HOOK_PROVIDER_ERROR,
type HookMessageProvider,
type HooksConfigResolved,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
resolveHookDeliver,
resolveHookProvider,
} from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
@@ -36,7 +32,15 @@ type HookDispatchers = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -167,18 +171,13 @@ export function createHooksRequestHandler(
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
return true;
}
const provider = resolveHookProvider(mapped.action.provider);
if (!provider) {
sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR });
return true;
}
const runId = dispatchAgentHook({
message: mapped.action.message,
name: mapped.action.name ?? "Hook",
wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "",
deliver: resolveHookDeliver(mapped.action.deliver),
provider,
deliver: mapped.action.deliver === true,
provider: mapped.action.provider ?? "last",
to: mapped.action.to,
model: mapped.action.model,
thinking: mapped.action.thinking,
+10 -2
View File
@@ -111,7 +111,7 @@ import {
startGatewayConfigReloader,
} from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js";
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js";
import { resolveHooksConfig } from "./hooks.js";
import {
isLoopbackAddress,
isLoopbackHost,
@@ -496,7 +496,15 @@ export async function startGatewayServer(
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
+1 -12
View File
@@ -6,10 +6,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test");
const PREV_CLAWDBOT_STATE_DIR = process.env.CLAWDBOT_STATE_DIR;
const PREV_CLAWDIS_STATE_DIR = process.env.CLAWDIS_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDIS_STATE_DIR;
vi.mock("node:os", () => ({
default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() },
@@ -26,18 +22,11 @@ describe("media store", () => {
afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
if (PREV_CLAWDBOT_STATE_DIR !== undefined)
process.env.CLAWDBOT_STATE_DIR = PREV_CLAWDBOT_STATE_DIR;
else delete process.env.CLAWDBOT_STATE_DIR;
if (PREV_CLAWDIS_STATE_DIR !== undefined)
process.env.CLAWDIS_STATE_DIR = PREV_CLAWDIS_STATE_DIR;
else delete process.env.CLAWDIS_STATE_DIR;
});
it("creates and returns media directory", async () => {
const dir = await store.ensureMediaDir();
expect(path.basename(dir)).toBe("media");
expect(dir).toContain("clawdbot-home-test");
const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true);
});
+1 -17
View File
@@ -14,16 +14,9 @@ const runtime = {
describe("web logout", () => {
const origHomedir = os.homedir;
let tmpDir: string;
let prevStateDir: string | undefined;
let prevClawdisStateDir: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
prevClawdisStateDir = process.env.CLAWDIS_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDIS_STATE_DIR;
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-"));
vi.spyOn(os, "homedir").mockReturnValue(tmpDir);
vi.resetModules();
@@ -43,14 +36,6 @@ describe("web logout", () => {
await fsPromises
.rm(tmpDir, { recursive: true, force: true })
.catch(() => {});
if (prevStateDir !== undefined) process.env.CLAWDBOT_STATE_DIR = prevStateDir;
else delete process.env.CLAWDBOT_STATE_DIR;
if (prevClawdisStateDir !== undefined)
process.env.CLAWDIS_STATE_DIR = prevClawdisStateDir;
else delete process.env.CLAWDIS_STATE_DIR;
// restore for safety
// eslint-disable-next-line @typescript-eslint/unbound-method
(os.homedir as unknown as typeof origHomedir) = origHomedir;
@@ -62,8 +47,7 @@ describe("web logout", () => {
async () => {
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
const rel = path.relative(tmpDir, WA_WEB_AUTH_DIR);
expect(rel && !rel.startsWith("..") && !path.isAbsolute(rel)).toBe(true);
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never });