Compare commits

..
Author SHA1 Message Date
Peter Steinberger 2f6842fdf4 fix: stabilize gateway test env cleanup (#1148) (thanks @TSavo) 2026-01-18 07:16:08 +00:00
tsavo 282dbe3167 refactor: add afterEach cleanup to all gateway tests
Added afterEach hooks with server/ws cleanup to:
- server.channels.test.ts (3 tests)
- server.config-apply.test.ts (2 tests)
- server.sessions-send.test.ts (already had this)

This ensures ports are properly released between tests, preventing
timeout issues from port conflicts.
2026-01-18 07:08:42 +00:00
tsavo 6d749b6e2c refactor: remove monkeypatching from gateway tests
Replace manual process.env backup/restore with vi.stubEnv():
- server.config-apply.test.ts: Simplified env var pattern
- server.channels.test.ts: Simplified env var pattern
- server.sessions-send.test.ts: Added afterEach cleanup hook, removed try-finally blocks from all 4 tests

Uses proper Vitest isolation instead of manual restoration.
2026-01-18 07:08:42 +00:00
Peter Steinberger c9c9516206 refactor(memory): extract sync + status helpers 2026-01-18 07:03:06 +00:00
Peter Steinberger d3b15c6afa ci: stabilize vitest runs 2026-01-18 06:58:54 +00:00
Peter SteinbergerandAustin Mudd f86b24c511 refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:55:04 +00:00
Peter Steinberger b5ddf08763 test: expand soul-evil coverage 2026-01-18 06:39:26 +00:00
Peter SteinbergerandAustin Mudd 367826f6e4 feat(session): add daily reset policy
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:37:37 +00:00
Peter SteinbergerandAndrew Lauppe f03c3b3f05 docs: update changelog for #1147
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:37:29 +00:00
Peter Steinberger ac1b2d8c40 chore(gate): fix lint and protocol 2026-01-18 06:31:02 +00:00
Peter Steinberger 2087f0c6a1 ci: bump vitest timeouts 2026-01-18 06:31:02 +00:00
Peter Steinberger bcfdcc6820 fix: keep bootstrap files in context report 2026-01-18 06:30:01 +00:00
Peter Steinberger b65acfcbb7 chore(lint): fix context report bootstrap destructure 2026-01-18 06:30:01 +00:00
Peter Steinberger f7fcfafb4c fix: resolve lint after rebase 2026-01-18 06:30:01 +00:00
Peter Steinberger 15606b4d88 test: cover bundled memory plugin package metadata 2026-01-18 06:30:01 +00:00
Peter Steinberger bb8f08734a build: package memory-core as a workspace plugin 2026-01-18 06:30:01 +00:00
Peter SteinbergerandAndrew Lauppe 0b00e591e1 fix(streaming): emit assistant deltas
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:24:52 +00:00
Peter Steinberger e39fd7dbb3 docs: update bundled hooks list 2026-01-18 06:23:09 +00:00
Peter Steinberger b8a82923e9 docs: add soul-evil hook docs 2026-01-18 06:21:00 +00:00
Peter Steinberger 28f8b7bafa refactor: add hook guards and test helpers 2026-01-18 06:15:24 +00:00
Peter Steinberger 32dd052260 chore: show plugin hooks in plugins info 2026-01-18 06:14:09 +00:00
Peter Steinberger 8f7f7ee7dc feat: add /exec session overrides 2026-01-18 06:12:54 +00:00
Peter Steinberger 1d8614c7c2 fix: align exec tool config and test timeouts 2026-01-18 06:12:53 +00:00
Peter SteinbergerandAndrew Lauppe 436c5fd751 fix(openai-http): reuse history markers for chat prompts
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:07:59 +00:00
126 changed files with 2577 additions and 2085 deletions

No files matched your search

+4 -1
View File
@@ -6,14 +6,17 @@ Docs: https://docs.clawd.bot
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- CLI: add `clawdbot acp` ACP bridge for IDE integrations.
### Fixes
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
## 2026.1.18-2
@@ -760,6 +760,10 @@ public struct SessionsPatchParams: Codable, Sendable {
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable?
public let exechost: AnyCodable?
public let execsecurity: AnyCodable?
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable?
@@ -773,6 +777,10 @@ public struct SessionsPatchParams: Codable, Sendable {
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
exechost: AnyCodable?,
execsecurity: AnyCodable?,
execask: AnyCodable?,
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
sendpolicy: AnyCodable?,
@@ -785,6 +793,10 @@ public struct SessionsPatchParams: Codable, Sendable {
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.exechost = exechost
self.execsecurity = execsecurity
self.execask = execask
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
self.sendpolicy = sendpolicy
@@ -798,6 +810,10 @@ public struct SessionsPatchParams: Codable, Sendable {
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case exechost = "execHost"
case execsecurity = "execSecurity"
case execask = "execAsk"
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy"
-194
View File
@@ -1,194 +0,0 @@
# Clawdbot ACP Bridge
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
## Overview
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
session keys so IDEs can reconnect to the same agent transcript or reset it on
request.
Key goals:
- Minimal ACP surface area (stdio, NDJSON).
- Stable session mapping across reconnects.
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
drive a Clawdbot Gateway session.
Quick steps:
1. Run a Gateway (local or remote).
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
3. Point the IDE to run `clawdbot acp` over stdio.
Example config:
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example run:
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Execution Model
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
- The bridge connects to the Gateway using existing auth config (or CLI flags).
- ACP `prompt` translates to Gateway `chat.send`.
- Gateway streaming events are translated back into ACP streaming events.
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
## Session Mapping
By default each ACP session is mapped to a dedicated Gateway session key:
- `acp:<uuid>` unless overridden.
You can override or reuse sessions in two ways:
1) CLI defaults
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session-label "support inbox"
clawdbot acp --reset-session
```
2) ACP metadata per session
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true,
"requireExisting": false
}
}
```
Rules:
- `sessionKey`: direct Gateway session key.
- `sessionLabel`: resolve an existing session by label.
- `resetSession`: mint a new transcript for the key before first use.
- `requireExisting`: fail if the key/label does not exist.
### Session Listing
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
sessions returned.
## Prompt Translation
ACP prompt inputs are converted into a Gateway `chat.send`:
- `text` and `resource` blocks become prompt text.
- `resource_link` with image mime types become attachments.
- The working directory can be prefixed into the prompt (default on, can be
disabled with `--no-prefix-cwd`).
Gateway streaming events are translated into ACP `message` and `tool_call`
updates. Terminal Gateway states map to ACP `done` with stop reasons:
- `complete` -> `stop`
- `aborted` -> `cancel`
- `error` -> `error`
## Auth + Gateway Discovery
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
- `--url` / `--token` / `--password` take precedence.
- Otherwise use configured `gateway.remote.*` settings.
## Operational Notes
- ACP sessions are stored in memory for the bridge process lifetime.
- Gateway session state is persisted by the Gateway itself.
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
- ACP runs can be canceled and the active run id is tracked per session.
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
## Testing
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
## Related Docs
- CLI usage: `docs/cli/acp.md`
- Session model: `docs/concepts/session.md`
- Session management internals: `docs/reference/session-management-compaction.md`
-143
View File
@@ -1,143 +0,0 @@
---
summary: "Run the ACP bridge for IDE integrations"
read_when:
- Setting up ACP-based IDE integrations
- Debugging ACP session routing to the Gateway
---
# acp
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
## Usage
```bash
clawdbot acp
# Remote Gateway
clawdbot acp --url wss://gateway-host:18789 --token <token>
# Attach to an existing session key
clawdbot acp --session agent:main:main
# Attach by label (must already exist)
clawdbot acp --session-label "support inbox"
# Reset the session key before the first prompt
clawdbot acp --session agent:main:main --reset-session
```
## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
it to drive a Clawdbot Gateway session.
1. Ensure the Gateway is running (local or remote).
2. Configure the Gateway target (config or flags).
3. Point your IDE to run `clawdbot acp` over stdio.
Example config (persisted):
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example direct run (no config write):
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Session mapping
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
To reuse a known session, pass a session key or label:
- `--session <key>`: use a specific Gateway session key.
- `--session-label <label>`: resolve an existing session by label.
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
If your ACP client supports metadata, you can override per session:
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true
}
}
```
Learn more about session keys at [/concepts/session](/concepts/session).
## Options
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
- `--token <token>`: Gateway auth token.
- `--password <password>`: Gateway auth password.
- `--session <key>`: default session key.
- `--session-label <label>`: default session label to resolve.
- `--require-existing`: fail if the session key/label does not exist.
- `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr.
+3 -2
View File
@@ -29,11 +29,12 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (2/2 ready)
Hooks (3/3 ready)
Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**Example (verbose):**
@@ -271,4 +272,4 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil
```
**See:** [soul-evil documentation](/hooks#soul-evil)
**See:** [SOUL Evil Hook](/hooks/soul-evil)
-7
View File
@@ -23,7 +23,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`message`](/cli/message)
- [`agent`](/cli/agent)
- [`agents`](/cli/agents)
- [`acp`](/cli/acp)
- [`status`](/cli/status)
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
@@ -126,7 +125,6 @@ clawdbot [--dev] [--profile <name>] <command>
list
add
delete
acp
status
health
sessions
@@ -508,11 +506,6 @@ Options:
- `--force`
- `--json`
### `acp`
Run the ACP bridge that connects IDEs to the Gateway.
See [`acp`](/cli/acp) for full options and examples.
### `status`
Show linked session health and recent recipients.
+18 -3
View File
@@ -54,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
## Lifecycle
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -93,7 +97,18 @@ Send these as standalone messages so they register.
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 120,
reset: {
// Defaults: mode=daily, atHour=4 (gateway host local time).
// If you also set idleMinutes, whichever expires first wins.
mode: "daily",
atHour: 4,
idleMinutes: 120
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main",
+2
View File
@@ -956,6 +956,8 @@
{
"group": "Automation & Hooks",
"pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",
+5 -1
View File
@@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior
session: {
scope: "per-sender",
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",
+23 -7
View File
@@ -2416,7 +2416,7 @@ Notes:
### `session`
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
Controls session scoping, reset policy, reset triggers, and where the session store is written.
```json5
{
@@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
@@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
sendPolicy: {
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } }
],
default: "allow"
}
],
default: "allow"
}
}
}
```
@@ -2456,6 +2465,13 @@ Fields:
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
- `atHour`: local hour (0-23) for the daily reset boundary.
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
+6 -2
View File
@@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/
```
**Check 2:** Is `idleMinutes` too short?
**Check 2:** Is the reset window too short?
```json
{
"session": {
"idleMinutes": 10080 // 7 days
"reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
}
}
```
+4 -1
View File
@@ -37,10 +37,11 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with two bundled hooks that are automatically discovered:
Clawdbot ships with three bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -511,6 +512,8 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:
+68
View File
@@ -0,0 +1,68 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
Clawdbot logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
clawdbot hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/hooks)
@@ -82,7 +82,8 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
+5 -1
View File
@@ -160,7 +160,11 @@ Example:
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
idleMinutes: 10080
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 10080
}
}
}
```
+9 -4
View File
@@ -880,14 +880,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`?
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
message starts a fresh session id for that chat key. This does not delete
transcripts — it just starts a new session.
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
You can also add an idle window; when both daily and idle resets are configured,
whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
```json5
{
session: {
idleMinutes: 240
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
}
}
```
+10
View File
@@ -41,6 +41,16 @@ Notes:
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
## Session overrides (`/exec`)
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
Send `/exec` with no arguments to show the current values.
Example:
```
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
## Exec approvals (macOS app)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
+2 -1
View File
@@ -12,7 +12,7 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems:
- **Commands**: standalone `/...` messages.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
@@ -77,6 +77,7 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
}
}
-1
View File
@@ -140,7 +140,6 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@buape/carbon": "0.0.0-beta-20260110172854",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
+6 -12
View File
@@ -13,9 +13,6 @@ importers:
.:
dependencies:
'@agentclientprotocol/sdk':
specifier: 0.13.0
version: 0.13.0(zod@4.3.5)
'@buape/carbon':
specifier: 0.0.0-beta-20260110172854
version: 0.0.0-beta-20260110172854(hono@4.11.4)
@@ -261,6 +258,12 @@ importers:
specifier: 40.0.0
version: 40.0.0
extensions/memory-core:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/msteams:
dependencies:
'@microsoft/agents-hosting':
@@ -342,11 +345,6 @@ importers:
packages:
'@agentclientprotocol/sdk@0.13.0':
resolution: {integrity: sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@anthropic-ai/sdk@0.71.2':
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
hasBin: true
@@ -4404,10 +4402,6 @@ packages:
snapshots:
'@agentclientprotocol/sdk@0.13.0(zod@4.3.5)':
dependencies:
zod: 4.3.5
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
describe("acp event mapper", () => {
it("extracts text and resource blocks into prompt text", () => {
const text = extractTextFromPrompt([
{ type: "text", text: "Hello" },
{ type: "resource", resource: { text: "File contents" } },
{ type: "resource_link", uri: "https://example.com", title: "Spec" },
{ type: "image", data: "abc", mimeType: "image/png" },
]);
expect(text).toBe(
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
);
});
it("extracts image blocks into gateway attachments", () => {
const attachments = extractAttachmentsFromPrompt([
{ type: "image", data: "abc", mimeType: "image/png" },
{ type: "image", data: "", mimeType: "image/png" },
{ type: "text", text: "ignored" },
]);
expect(attachments).toEqual([
{
type: "image",
mimeType: "image/png",
content: "abc",
},
]);
});
});
-73
View File
@@ -1,73 +0,0 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
export type GatewayAttachment = {
type: string;
mimeType: string;
content: string;
};
export function extractTextFromPrompt(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text") {
parts.push(block.text);
continue;
}
if (block.type === "resource") {
const resource = block.resource as { text?: string } | undefined;
if (resource?.text) parts.push(resource.text);
continue;
}
if (block.type === "resource_link") {
const title = block.title ? ` (${block.title})` : "";
const uri = block.uri ?? "";
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
parts.push(line);
}
}
return parts.join("\n");
}
export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
const attachments: GatewayAttachment[] = [];
for (const block of prompt) {
if (block.type !== "image") continue;
const image = block as ImageContent;
if (!image.data || !image.mimeType) continue;
attachments.push({
type: "image",
mimeType: image.mimeType,
content: image.data,
});
}
return attachments;
}
export function formatToolTitle(
name: string | undefined,
args: Record<string, unknown> | undefined,
): string {
const base = name ?? "tool";
if (!args || Object.keys(args).length === 0) return base;
const parts = Object.entries(args).map(([key, value]) => {
const raw = typeof value === "string" ? value : JSON.stringify(value);
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
return `${key}: ${safe}`;
});
return `${base}: ${parts.join(", ")}`;
}
export function inferToolKind(name?: string): ToolKind | undefined {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("search") || normalized.includes("find")) return "search";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
return "execute";
}
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
return "other";
}
-4
View File
@@ -1,4 +0,0 @@
export { serveAcpGateway } from "./server.js";
export { createInMemorySessionStore } from "./session.js";
export type { AcpSessionStore } from "./session.js";
export type { AcpServerOptions } from "./types.js";
-35
View File
@@ -1,35 +0,0 @@
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "boolean") return value;
}
return undefined;
}
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env node
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isMainModule } from "../infra/is-main.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl,
});
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const token =
opts.gatewayToken ??
(isRemoteMode ? remote?.token?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
auth.token;
const password =
opts.gatewayPassword ??
(isRemoteMode ? remote?.password?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
auth.password;
let agent: AcpGatewayAgent | null = null;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
password: password || undefined,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
void agent?.handleGatewayEvent(evt);
},
onHelloOk: () => {
agent?.handleGatewayReconnect();
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
},
});
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
new AgentSideConnection((conn) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;
}, stream);
gateway.start();
}
function parseArgs(args: string[]): AcpServerOptions {
const opts: AcpServerOptions = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
opts.gatewayUrl = args[i + 1];
i += 1;
continue;
}
if (arg === "--token" || arg === "--gateway-token") {
opts.gatewayToken = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
continue;
}
if (arg === "--session-label") {
opts.defaultSessionLabel = args[i + 1];
i += 1;
continue;
}
if (arg === "--require-existing") {
opts.requireExistingSession = true;
continue;
}
if (arg === "--reset-session") {
opts.resetSession = true;
continue;
}
if (arg === "--no-prefix-cwd") {
opts.prefixCwd = false;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
return opts;
}
function printHelp(): void {
console.log(`Usage: clawdbot acp [options]
Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--password <password> Gateway auth password
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
function createGateway(resolveLabelKey = "agent:main:label"): {
gateway: GatewayClient;
request: ReturnType<typeof vi.fn>;
} {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.resolve" && "label" in params) {
return { ok: true, key: resolveLabelKey };
}
if (method === "sessions.resolve" && "key" in params) {
return { ok: true, key: params.key as string };
}
return { ok: true };
});
return {
gateway: { request } as unknown as GatewayClient,
request,
};
}
describe("acp session mapper", () => {
it("prefers explicit sessionLabel over sessionKey", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: {},
});
expect(key).toBe("agent:main:label");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
});
it("lets meta sessionKey override default label", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: { defaultSessionLabel: "default-label" },
});
expect(key).toBe("agent:main:override");
expect(request).not.toHaveBeenCalled();
});
});
-95
View File
@@ -1,95 +0,0 @@
import type { GatewayClient } from "../gateway/client.js";
import type { AcpServerOptions } from "./types.js";
import { readBool, readString } from "./meta.js";
export type AcpSessionMeta = {
sessionKey?: string;
sessionLabel?: string;
resetSession?: boolean;
requireExisting?: boolean;
prefixCwd?: boolean;
};
export function parseSessionMeta(meta: unknown): AcpSessionMeta {
if (!meta || typeof meta !== "object") return {};
const record = meta as Record<string, unknown>;
return {
sessionKey: readString(record, ["sessionKey", "session", "key"]),
sessionLabel: readString(record, ["sessionLabel", "label"]),
resetSession: readBool(record, ["resetSession", "reset"]),
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
prefixCwd: readBool(record, ["prefixCwd"]),
};
}
export async function resolveSessionKey(params: {
meta: AcpSessionMeta;
fallbackKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<string> {
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
const requireExisting =
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
if (params.meta.sessionLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: params.meta.sessionLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
}
return resolved.key;
}
if (params.meta.sessionKey) {
if (!requireExisting) return params.meta.sessionKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: params.meta.sessionKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
}
return resolved.key;
}
if (requestedLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: requestedLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
}
return resolved.key;
}
if (requestedKey) {
if (!requireExisting) return requestedKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: requestedKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${requestedKey}`);
}
return resolved.key;
}
return params.fallbackKey;
}
export async function resetSessionIfNeeded(params: {
meta: AcpSessionMeta;
sessionKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<void> {
const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
if (!resetSession) return;
await params.gateway.request("sessions.reset", { key: params.sessionKey });
}
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it, afterEach } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
const store = createInMemorySessionStore();
afterEach(() => {
store.clearAllSessionsForTest();
});
it("tracks active runs and clears on cancel", () => {
const session = store.createSession({
sessionKey: "acp:test",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-1", controller);
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
const cancelled = store.cancelActiveRun(session.sessionId);
expect(cancelled).toBe(true);
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
});
-93
View File
@@ -1,93 +0,0 @@
import { randomUUID } from "node:crypto";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
}) => AcpSession;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
clearActiveRun: (sessionId: string) => void;
cancelActiveRun: (sessionId: string) => boolean;
clearAllSessionsForTest: () => void;
};
export function createInMemorySessionStore(): AcpSessionStore {
const sessions = new Map<string, AcpSession>();
const runIdToSessionId = new Map<string, string>();
const createSession: AcpSessionStore["createSession"] = (params) => {
const sessionId = params.sessionId ?? randomUUID();
const session: AcpSession = {
sessionId,
sessionKey: params.sessionKey,
cwd: params.cwd,
createdAt: Date.now(),
abortController: null,
activeRunId: null,
};
sessions.set(sessionId, session);
return session;
};
const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
const sessionId = runIdToSessionId.get(runId);
return sessionId ? sessions.get(sessionId) : undefined;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (
sessionId,
runId,
abortController,
) => {
const session = sessions.get(sessionId);
if (!session) return;
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
};
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return;
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.activeRunId = null;
session.abortController = null;
};
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) return false;
session.abortController.abort();
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.abortController = null;
session.activeRunId = null;
return true;
};
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
for (const session of sessions.values()) {
session.abortController?.abort();
}
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
clearAllSessionsForTest,
};
}
export const defaultAcpSessionStore = createInMemorySessionStore();
-420
View File
@@ -1,420 +0,0 @@
import { randomUUID } from "node:crypto";
import type {
Agent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
import { readBool, readNumber, readString } from "./meta.js";
import {
extractAttachmentsFromPrompt,
extractTextFromPrompt,
formatToolTitle,
inferToolKind,
} from "./event-mapper.js";
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js";
type PendingPrompt = {
sessionId: string;
sessionKey: string;
idempotencyKey: string;
resolve: (response: PromptResponse) => void;
reject: (err: Error) => void;
sentTextLength?: number;
sentText?: string;
toolCalls?: Set<string>;
};
type AcpGatewayAgentOptions = AcpServerOptions & {
sessionStore?: AcpSessionStore;
};
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
private opts: AcpGatewayAgentOptions;
private log: (msg: string) => void;
private sessionStore: AcpSessionStore;
private pendingPrompts = new Map<string, PendingPrompt>();
constructor(
connection: AgentSideConnection,
gateway: GatewayClient,
opts: AcpGatewayAgentOptions = {},
) {
this.connection = connection;
this.gateway = gateway;
this.opts = opts;
this.log = opts.verbose
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
: () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
}
start(): void {
this.log("ready");
}
handleGatewayReconnect(): void {
this.log("gateway reconnected");
}
handleGatewayDisconnect(reason: string): void {
this.log(`gateway disconnected: ${reason}`);
for (const pending of this.pendingPrompts.values()) {
pending.reject(new Error(`Gateway disconnected: ${reason}`));
this.sessionStore.clearActiveRun(pending.sessionId);
}
this.pendingPrompts.clear();
}
async handleGatewayEvent(evt: EventFrame): Promise<void> {
if (evt.event === "chat") {
await this.handleChatEvent(evt);
return;
}
if (evt.event === "agent") {
await this.handleAgentEvent(evt);
}
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: false,
embeddedContext: true,
},
mcpCapabilities: {
http: false,
sse: false,
},
sessionCapabilities: {
list: {},
},
},
agentInfo: ACP_AGENT_INFO,
authMethods: [],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: `acp:${sessionId}`,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
return { sessionId: session.sessionId };
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: params.sessionId,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId: params.sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
return {};
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
const limit = readNumber(params._meta, ["limit"]) ?? 100;
const result = await this.gateway.request<SessionsListResult>("sessions.list", { limit });
const cwd = params.cwd ?? process.cwd();
return {
sessions: result.sessions.map((session) => ({
sessionId: session.key,
cwd,
title: session.displayName ?? session.label ?? session.key,
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
_meta: {
sessionKey: session.key,
kind: session.kind,
channel: session.channel,
},
})),
nextCursor: null,
};
}
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
return {};
}
async setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse | void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (!params.modeId) return {};
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
thinkingLevel: params.modeId,
});
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
} catch (err) {
this.log(`setSessionMode error: ${String(err)}`);
}
return {};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (session.abortController) {
this.sessionStore.cancelActiveRun(params.sessionId);
}
const abortController = new AbortController();
const runId = randomUUID();
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
const meta = parseSessionMeta(params._meta);
const userText = extractTextFromPrompt(params.prompt);
const attachments = extractAttachmentsFromPrompt(params.prompt);
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
return new Promise<PromptResponse>((resolve, reject) => {
this.pendingPrompts.set(params.sessionId, {
sessionId: params.sessionId,
sessionKey: session.sessionKey,
idempotencyKey: runId,
resolve,
reject,
});
this.gateway
.request(
"chat.send",
{
sessionKey: session.sessionKey,
message,
attachments: attachments.length > 0 ? attachments : undefined,
idempotencyKey: runId,
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
},
{ expectFinal: true },
)
.catch((err) => {
this.pendingPrompts.delete(params.sessionId);
this.sessionStore.clearActiveRun(params.sessionId);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
async cancel(params: CancelNotification): Promise<void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) return;
this.sessionStore.cancelActiveRun(params.sessionId);
try {
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
} catch (err) {
this.log(`cancel error: ${String(err)}`);
}
const pending = this.pendingPrompts.get(params.sessionId);
if (pending) {
this.pendingPrompts.delete(params.sessionId);
pending.resolve({ stopReason: "cancelled" });
}
}
private async handleAgentEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const stream = payload.stream as string | undefined;
const data = payload.data as Record<string, unknown> | undefined;
const sessionKey = payload.sessionKey as string | undefined;
if (!stream || !data || !sessionKey) return;
if (stream !== "tool") return;
const phase = data.phase as string | undefined;
const name = data.name as string | undefined;
const toolCallId = data.toolCallId as string | undefined;
if (!toolCallId) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (phase === "start") {
if (!pending.toolCalls) pending.toolCalls = new Set();
if (pending.toolCalls.has(toolCallId)) return;
pending.toolCalls.add(toolCallId);
const args = data.args as Record<string, unknown> | undefined;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: formatToolTitle(name, args),
status: "in_progress",
rawInput: args,
kind: inferToolKind(name),
},
});
return;
}
if (phase === "result") {
const isError = Boolean(data.isError);
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: isError ? "failed" : "completed",
rawOutput: data.result,
},
});
}
}
private async handleChatEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const sessionKey = payload.sessionKey as string | undefined;
const state = payload.state as string | undefined;
const runId = payload.runId as string | undefined;
const messageData = payload.message as Record<string, unknown> | undefined;
if (!sessionKey || !state) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (runId && pending.idempotencyKey !== runId) return;
if (state === "delta" && messageData) {
await this.handleDeltaEvent(pending.sessionId, messageData);
return;
}
if (state === "final") {
this.finishPrompt(pending.sessionId, pending, "end_turn");
return;
}
if (state === "aborted") {
this.finishPrompt(pending.sessionId, pending, "cancelled");
return;
}
if (state === "error") {
this.finishPrompt(pending.sessionId, pending, "refusal");
}
}
private async handleDeltaEvent(
sessionId: string,
messageData: Record<string, unknown>,
): Promise<void> {
const content = messageData.content as Array<{ type: string; text?: string }> | undefined;
const fullText = content?.find((c) => c.type === "text")?.text ?? "";
const pending = this.pendingPrompts.get(sessionId);
if (!pending) return;
const sentSoFar = pending.sentTextLength ?? 0;
if (fullText.length <= sentSoFar) return;
const newText = fullText.slice(sentSoFar);
pending.sentTextLength = fullText.length;
pending.sentText = fullText;
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: newText },
},
});
}
private finishPrompt(
sessionId: string,
pending: PendingPrompt,
stopReason: StopReason,
): void {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
pending.resolve({ stopReason });
}
private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
for (const pending of this.pendingPrompts.values()) {
if (pending.sessionKey === sessionKey) return pending;
}
return undefined;
}
}
-30
View File
@@ -1,30 +0,0 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
cwd: string;
createdAt: number;
abortController: AbortController | null;
activeRunId: string | null;
};
export type AcpServerOptions = {
gatewayUrl?: string;
gatewayToken?: string;
gatewayPassword?: string;
defaultSessionKey?: string;
defaultSessionLabel?: string;
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
verbose?: boolean;
};
export const ACP_AGENT_INFO = {
name: "clawdbot-acp",
title: "Clawdbot ACP Gateway",
version: VERSION,
};
+3 -4
View File
@@ -1,10 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import {
clearInternalHooks,
registerInternalHook,
@@ -29,7 +28,7 @@ describe("resolveBootstrapFilesForRun", () => {
];
});
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.some((file) => file.name === "EXTRA.md")).toBe(true);
@@ -54,7 +53,7 @@ describe("resolveBootstrapContextForRun", () => {
];
});
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bootstrap-"));
const workspaceDir = await makeTempWorkspace("clawdbot-bootstrap-");
const result = await resolveBootstrapContextForRun({ workspaceDir });
const extra = result.contextFiles.find((file) => file.path === "EXTRA.md");
+8
View File
@@ -8,6 +8,14 @@ import {
import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
export function makeBootstrapWarn(params: {
sessionLabel: string;
warn?: (message: string) => void;
}): ((message: string) => void) | undefined {
if (!params.warn) return undefined;
return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`);
}
export async function resolveBootstrapFilesForRun(params: {
workspaceDir: string;
config?: ClawdbotConfig;
+2 -2
View File
@@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import {
appendImagePathsToPrompt,
@@ -73,7 +73,7 @@ export async function runCliAgent(params: {
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
+2 -2
View File
@@ -16,7 +16,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import { resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -181,7 +181,7 @@ export async function compactEmbeddedPiSession(params: {
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({
+1
View File
@@ -218,6 +218,7 @@ export async function runEmbeddedPiAgent(
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
execOverrides: params.execOverrides,
bashElevated: params.bashElevated,
timeoutMs: params.timeoutMs,
runId: params.runId,
+4 -4
View File
@@ -17,7 +17,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import {
isCloudCodeAssistFormatError,
@@ -64,7 +64,7 @@ import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
import { mapThinkingLevel } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -126,14 +126,14 @@ export async function runEmbeddedAttempt(
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
+2 -1
View File
@@ -2,7 +2,7 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
@@ -34,6 +34,7 @@ export type RunEmbeddedPiAgentParams = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;
+2 -1
View File
@@ -4,7 +4,7 @@ import type { discoverAuthStorage, discoverModels } from "@mariozechner/pi-codin
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { ExecElevatedDefaults } from "../../bash-tools.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
@@ -39,6 +39,7 @@ export type EmbeddedRunAttemptParams = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
toolResultFormat?: ToolResultFormat;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;
@@ -108,13 +108,19 @@ export function handleMessageUpdate(
})
.trim();
if (next && next !== ctx.state.lastStreamedAssistant) {
const previousText = ctx.state.lastStreamedAssistant ?? "";
ctx.state.lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } = parseReplyDirectives(next);
const { text: previousCleanedText } = parseReplyDirectives(previousText);
const deltaText = cleanedText.startsWith(previousCleanedText)
? cleanedText.slice(previousCleanedText.length)
: cleanedText;
emitAgentEvent({
runId: ctx.params.runId,
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
@@ -122,6 +128,7 @@ export function handleMessageUpdate(
stream: "assistant",
data: {
text: cleanedText,
delta: deltaText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
@@ -146,4 +146,42 @@ describe("subscribeEmbeddedPiSession", () => {
expect(combined).toBe("Final answer");
},
);
it("emits delta chunks in agent events for streaming assistant text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " world" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads[0]?.text).toBe("Hello");
expect(payloads[0]?.delta).toBe("Hello");
expect(payloads[1]?.text).toBe("Hello world");
expect(payloads[1]?.delta).toBe(" world");
});
});
@@ -52,7 +52,7 @@ describe("Agent-specific sandbox config", () => {
it(
"should use global sandbox config when no agent-specific config exists",
{ timeout: 15_000 },
{ timeout: 60_000 },
async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", { timeout: 15_000 }, async () => {
it("resolves sandbox scope deterministically", { timeout: 60_000 }, async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
expect(resolveSandboxScope({})).toBe("agent");
+14
View File
@@ -367,6 +367,20 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "exec",
nativeName: "exec",
description: "Set exec defaults for this session.",
textAlias: "/exec",
args: [
{
name: "options",
description: "host=... security=... ask=... node=...",
type: "string",
},
],
argsParsing: "none",
}),
defineChatCommand({
key: "model",
nativeName: "model",
@@ -147,6 +147,47 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("shows current exec defaults when /exec has no argument", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/exec",
From: "+1222",
To: "+1222",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
node: "mac-1",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain(
"Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.",
);
expect(text).toContain(
"Options: host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("persists elevated off and reflects it in /status (even when default is on)", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { extractStatusDirective } from "./reply/directives.js";
import {
extractElevatedDirective,
extractExecDirective,
extractQueueDirective,
extractReasoningDirective,
extractReplyToTag,
@@ -112,6 +113,26 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("");
});
it("matches exec directive with options", () => {
const res = extractExecDirective(
"please /exec host=gateway security=allowlist ask=on-miss node=mac-mini now",
);
expect(res.hasDirective).toBe(true);
expect(res.execHost).toBe("gateway");
expect(res.execSecurity).toBe("allowlist");
expect(res.execAsk).toBe("on-miss");
expect(res.execNode).toBe("mac-mini");
expect(res.cleaned).toBe("please now");
});
it("captures invalid exec host values", () => {
const res = extractExecDirective("/exec host=spaceship");
expect(res.hasDirective).toBe(true);
expect(res.execHost).toBeUndefined();
expect(res.rawExecHost).toBe("spaceship");
expect(res.invalidHost).toBe(true);
});
it("matches queue directive", () => {
const res = extractQueueDirective("please /queue interrupt now");
expect(res.hasDirective).toBe(true);
@@ -97,7 +97,7 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("stages inbound media into the sandbox workspace", { timeout: 15_000 }, async () => {
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
+1
View File
@@ -5,6 +5,7 @@ export {
extractVerboseDirective,
} from "./reply/directives.js";
export { getReplyFromConfig } from "./reply/get-reply.js";
export { extractExecDirective } from "./reply/exec.js";
export { extractQueueDirective } from "./reply/queue.js";
export { extractReplyToTag } from "./reply/reply-tags.js";
export type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -226,6 +226,7 @@ export async function runAgentTurnWithFallback(params: {
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
@@ -123,6 +123,7 @@ export async function runMemoryFlushIfNeeded(params: {
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId: flushRunId,
@@ -1,8 +1,13 @@
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import {
resolveAgentConfig,
resolveAgentDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
@@ -23,6 +28,36 @@ import {
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
function resolveExecDefaults(params: {
cfg: ClawdbotConfig;
sessionEntry?: SessionEntry;
agentId?: string;
}): { host: ExecHost; security: ExecSecurity; ask: ExecAsk; node?: string } {
const globalExec = params.cfg.tools?.exec;
const agentExec = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.exec
: undefined;
return {
host:
(params.sessionEntry?.execHost as ExecHost | undefined) ??
(agentExec?.host as ExecHost | undefined) ??
(globalExec?.host as ExecHost | undefined) ??
"sandbox",
security:
(params.sessionEntry?.execSecurity as ExecSecurity | undefined) ??
(agentExec?.security as ExecSecurity | undefined) ??
(globalExec?.security as ExecSecurity | undefined) ??
"deny",
ask:
(params.sessionEntry?.execAsk as ExecAsk | undefined) ??
(agentExec?.ask as ExecAsk | undefined) ??
(globalExec?.ask as ExecAsk | undefined) ??
"on-miss",
node:
(params.sessionEntry?.execNode as string | undefined) ?? agentExec?.node ?? globalExec?.node,
};
}
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
@@ -189,6 +224,42 @@ export async function handleDirectiveOnly(params: {
}),
};
}
if (directives.hasExecDirective) {
if (directives.invalidExecHost) {
return {
text: `Unrecognized exec host "${directives.rawExecHost ?? ""}". Valid hosts: sandbox, gateway, node.`,
};
}
if (directives.invalidExecSecurity) {
return {
text: `Unrecognized exec security "${directives.rawExecSecurity ?? ""}". Valid: deny, allowlist, full.`,
};
}
if (directives.invalidExecAsk) {
return {
text: `Unrecognized exec ask "${directives.rawExecAsk ?? ""}". Valid: off, on-miss, always.`,
};
}
if (directives.invalidExecNode) {
return {
text: "Exec node requires a value.",
};
}
if (!directives.hasExecOptions) {
const execDefaults = resolveExecDefaults({
cfg: params.cfg,
sessionEntry,
agentId: activeAgentId,
});
const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)";
return {
text: withOptions(
`Current exec defaults: host=${execDefaults.host}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`,
"host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=<id>",
),
};
}
}
const queueAck = maybeHandleQueueDirective({
directives,
@@ -254,6 +325,20 @@ export async function handleDirectiveOnly(params: {
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
}
if (modelSelection) {
if (modelSelection.isDefault) {
delete sessionEntry.providerOverride;
@@ -355,6 +440,16 @@ export async function handleDirectiveOnly(params: {
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}
if (directives.hasExecDirective && directives.hasExecOptions) {
const execParts: string[] = [];
if (directives.execHost) execParts.push(`host=${directives.execHost}`);
if (directives.execSecurity) execParts.push(`security=${directives.execSecurity}`);
if (directives.execAsk) execParts.push(`ask=${directives.execAsk}`);
if (directives.execNode) execParts.push(`node=${directives.execNode}`);
if (execParts.length > 0) {
parts.push(formatDirectiveAck(`Exec defaults set (${execParts.join(", ")}).`));
}
}
if (shouldDowngradeXHigh) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
@@ -1,9 +1,11 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
import {
extractElevatedDirective,
extractExecDirective,
extractReasoningDirective,
extractStatusDirective,
extractThinkDirective,
@@ -27,6 +29,20 @@ export type InlineDirectives = {
hasElevatedDirective: boolean;
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasExecDirective: boolean;
execHost?: ExecHost;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;
rawExecHost?: string;
rawExecSecurity?: string;
rawExecAsk?: string;
rawExecNode?: string;
hasExecOptions: boolean;
invalidExecHost: boolean;
invalidExecSecurity: boolean;
invalidExecAsk: boolean;
invalidExecNode: boolean;
hasStatusDirective: boolean;
hasModelDirective: boolean;
rawModelDirective?: string;
@@ -83,10 +99,27 @@ export function parseInlineDirectives(
hasDirective: false,
}
: extractElevatedDirective(reasoningCleaned);
const {
cleaned: execCleaned,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidHost: invalidExecHost,
invalidSecurity: invalidExecSecurity,
invalidAsk: invalidExecAsk,
invalidNode: invalidExecNode,
hasDirective: hasExecDirective,
} = extractExecDirective(elevatedCleaned);
const allowStatusDirective = options?.allowStatusDirective !== false;
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective
? extractStatusDirective(elevatedCleaned)
: { cleaned: elevatedCleaned, hasDirective: false };
? extractStatusDirective(execCleaned)
: { cleaned: execCleaned, hasDirective: false };
const {
cleaned: modelCleaned,
rawModel,
@@ -124,6 +157,20 @@ export function parseInlineDirectives(
hasElevatedDirective,
elevatedLevel,
rawElevatedLevel,
hasExecDirective,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidExecHost,
invalidExecSecurity,
invalidExecAsk,
invalidExecNode,
hasStatusDirective,
hasModelDirective,
rawModelDirective: rawModel,
@@ -156,6 +203,7 @@ export function isDirectiveOnly(params: {
!directives.hasVerboseDirective &&
!directives.hasReasoningDirective &&
!directives.hasElevatedDirective &&
!directives.hasExecDirective &&
!directives.hasModelDirective &&
!directives.hasQueueDirective
)
@@ -118,6 +118,24 @@ export async function persistInlineDirectives(params: {
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
updated = true;
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
updated = true;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
updated = true;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
updated = true;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
updated = true;
}
}
const modelDirective =
directives.hasModelDirective && params.effectiveModelDirective
+1
View File
@@ -153,3 +153,4 @@ export function extractStatusDirective(body?: string): {
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js";
+1
View File
@@ -0,0 +1 @@
export { extractExecDirective } from "./exec/directive.js";
+202
View File
@@ -0,0 +1,202 @@
import type { ExecAsk, ExecHost, ExecSecurity } from "../../../infra/exec-approvals.js";
type ExecDirectiveParse = {
cleaned: string;
hasDirective: boolean;
execHost?: ExecHost;
execSecurity?: ExecSecurity;
execAsk?: ExecAsk;
execNode?: string;
rawExecHost?: string;
rawExecSecurity?: string;
rawExecAsk?: string;
rawExecNode?: string;
hasExecOptions: boolean;
invalidHost: boolean;
invalidSecurity: boolean;
invalidAsk: boolean;
invalidNode: boolean;
};
function normalizeExecHost(value?: string): ExecHost | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node")
return normalized;
return undefined;
}
function normalizeExecSecurity(value?: string): ExecSecurity | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full")
return normalized;
return undefined;
}
function normalizeExecAsk(value?: string): ExecAsk | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return undefined;
}
function parseExecDirectiveArgs(raw: string): Omit<
ExecDirectiveParse,
"cleaned" | "hasDirective"
> & {
consumed: number;
} {
let i = 0;
const len = raw.length;
while (i < len && /\s/.test(raw[i])) i += 1;
if (raw[i] === ":") {
i += 1;
while (i < len && /\s/.test(raw[i])) i += 1;
}
let consumed = i;
let execHost: ExecHost | undefined;
let execSecurity: ExecSecurity | undefined;
let execAsk: ExecAsk | undefined;
let execNode: string | undefined;
let rawExecHost: string | undefined;
let rawExecSecurity: string | undefined;
let rawExecAsk: string | undefined;
let rawExecNode: string | undefined;
let hasExecOptions = false;
let invalidHost = false;
let invalidSecurity = false;
let invalidAsk = false;
let invalidNode = false;
const takeToken = (): string | null => {
if (i >= len) return null;
const start = i;
while (i < len && !/\s/.test(raw[i])) i += 1;
if (start === i) return null;
const token = raw.slice(start, i);
while (i < len && /\s/.test(raw[i])) i += 1;
return token;
};
const splitToken = (token: string): { key: string; value: string } | null => {
const eq = token.indexOf("=");
const colon = token.indexOf(":");
const idx = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon);
if (idx === -1) return null;
const key = token.slice(0, idx).trim().toLowerCase();
const value = token.slice(idx + 1).trim();
if (!key) return null;
return { key, value };
};
while (i < len) {
const token = takeToken();
if (!token) break;
const parsed = splitToken(token);
if (!parsed) break;
const { key, value } = parsed;
if (key === "host") {
rawExecHost = value;
execHost = normalizeExecHost(value);
if (!execHost) invalidHost = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "security") {
rawExecSecurity = value;
execSecurity = normalizeExecSecurity(value);
if (!execSecurity) invalidSecurity = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "ask") {
rawExecAsk = value;
execAsk = normalizeExecAsk(value);
if (!execAsk) invalidAsk = true;
hasExecOptions = true;
consumed = i;
continue;
}
if (key === "node") {
rawExecNode = value;
const trimmed = value.trim();
if (!trimmed) {
invalidNode = true;
} else {
execNode = trimmed;
}
hasExecOptions = true;
consumed = i;
continue;
}
break;
}
return {
consumed,
execHost,
execSecurity,
execAsk,
execNode,
rawExecHost,
rawExecSecurity,
rawExecAsk,
rawExecNode,
hasExecOptions,
invalidHost,
invalidSecurity,
invalidAsk,
invalidNode,
};
}
export function extractExecDirective(body?: string): ExecDirectiveParse {
if (!body) {
return {
cleaned: "",
hasDirective: false,
hasExecOptions: false,
invalidHost: false,
invalidSecurity: false,
invalidAsk: false,
invalidNode: false,
};
}
const re = /(?:^|\s)\/exec(?=$|\s|:)/i;
const match = re.exec(body);
if (!match) {
return {
cleaned: body.trim(),
hasDirective: false,
hasExecOptions: false,
invalidHost: false,
invalidSecurity: false,
invalidAsk: false,
invalidNode: false,
};
}
const start = match.index + match[0].indexOf("/exec");
const argsStart = start + "/exec".length;
const parsed = parseExecDirectiveArgs(body.slice(argsStart));
const cleanedRaw = `${body.slice(0, start)} ${body.slice(argsStart + parsed.consumed)}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
hasDirective: true,
execHost: parsed.execHost,
execSecurity: parsed.execSecurity,
execAsk: parsed.execAsk,
execNode: parsed.execNode,
rawExecHost: parsed.rawExecHost,
rawExecSecurity: parsed.rawExecSecurity,
rawExecAsk: parsed.rawExecAsk,
rawExecNode: parsed.rawExecNode,
hasExecOptions: parsed.hasExecOptions,
invalidHost: parsed.invalidHost,
invalidSecurity: parsed.invalidSecurity,
invalidAsk: parsed.invalidAsk,
invalidNode: parsed.invalidNode,
};
}
+1
View File
@@ -158,6 +158,7 @@ export function createFollowupRunner(params: {
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
reasoningLevel: queued.run.reasoningLevel,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
@@ -110,6 +110,20 @@ export async function applyInlineDirectiveOverrides(params: {
hasVerboseDirective: false,
hasReasoningDirective: false,
hasElevatedDirective: false,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
hasStatusDirective: false,
hasModelDirective: false,
hasQueueDirective: false,
@@ -206,6 +220,7 @@ export async function applyInlineDirectiveOverrides(params: {
directives.hasVerboseDirective ||
directives.hasReasoningDirective ||
directives.hasElevatedDirective ||
directives.hasExecDirective ||
directives.hasModelDirective ||
directives.hasQueueDirective ||
directives.hasStatusDirective;
@@ -15,6 +15,20 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives {
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
hasStatusDirective: false,
hasModelDirective: false,
rawModelDirective: undefined,
@@ -1,3 +1,4 @@
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
@@ -21,6 +22,7 @@ import { stripInlineStatus } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
export type ReplyDirectiveContinuation = {
commandSource: string;
@@ -38,6 +40,7 @@ export type ReplyDirectiveContinuation = {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
execOverrides?: ExecOverrides;
blockStreamingEnabled: boolean;
blockReplyChunking?: {
minChars: number;
@@ -59,6 +62,21 @@ export type ReplyDirectiveContinuation = {
};
};
function resolveExecOverrides(params: {
directives: InlineDirectives;
sessionEntry?: SessionEntry;
}): ExecOverrides | undefined {
const host =
params.directives.execHost ?? (params.sessionEntry?.execHost as ExecOverrides["host"]);
const security =
params.directives.execSecurity ??
(params.sessionEntry?.execSecurity as ExecOverrides["security"]);
const ask = params.directives.execAsk ?? (params.sessionEntry?.execAsk as ExecOverrides["ask"]);
const node = params.directives.execNode ?? params.sessionEntry?.execNode;
if (!host && !security && !ask && !node) return undefined;
return { host, security, ask, node };
}
export type ReplyDirectiveResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| { kind: "continue"; result: ReplyDirectiveContinuation };
@@ -190,11 +208,33 @@ export async function resolveReplyDirectives(params: {
};
}
}
if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasExecDirective) {
if (parsedDirectives.execSecurity !== "deny") {
parsedDirectives = {
...parsedDirectives,
hasExecDirective: false,
execHost: undefined,
execSecurity: undefined,
execAsk: undefined,
execNode: undefined,
rawExecHost: undefined,
rawExecSecurity: undefined,
rawExecAsk: undefined,
rawExecNode: undefined,
hasExecOptions: false,
invalidExecHost: false,
invalidExecSecurity: false,
invalidExecAsk: false,
invalidExecNode: false,
};
}
}
const hasInlineDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
parsedDirectives.hasReasoningDirective ||
parsedDirectives.hasElevatedDirective ||
parsedDirectives.hasExecDirective ||
parsedDirectives.hasModelDirective ||
parsedDirectives.hasQueueDirective;
if (hasInlineDirective) {
@@ -405,6 +445,7 @@ export async function resolveReplyDirectives(params: {
model = applyResult.model;
contextTokens = applyResult.contextTokens;
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
const execOverrides = resolveExecOverrides({ directives, sessionEntry });
return {
kind: "continue",
@@ -424,6 +465,7 @@ export async function resolveReplyDirectives(params: {
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
+5
View File
@@ -10,6 +10,7 @@ import {
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionFilePath,
@@ -47,6 +48,7 @@ import type { TypingController } from "./typing.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
const BARE_SESSION_RESET_PROMPT =
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
@@ -69,6 +71,7 @@ type RunPreparedReplyParams = {
resolvedVerboseLevel: VerboseLevel | undefined;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel: ElevatedLevel;
execOverrides?: ExecOverrides;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
blockStreamingEnabled: boolean;
@@ -227,6 +230,7 @@ export async function runPreparedReply(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
abortedLastRun,
} = params;
let currentSystemSent = systemSent;
@@ -430,6 +434,7 @@ export async function runPreparedReply(
verboseLevel: resolvedVerboseLevel,
reasoningLevel: resolvedReasoningLevel,
elevatedLevel: resolvedElevatedLevel,
execOverrides,
bashElevated: {
enabled: elevatedEnabled,
allowed: elevatedAllowed,
+2
View File
@@ -157,6 +157,7 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
@@ -241,6 +242,7 @@ export async function getReplyFromConfig(
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
execOverrides,
elevatedEnabled,
elevatedAllowed,
blockStreamingEnabled,
+2
View File
@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../../config/config.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { OriginatingChannelType } from "../../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue";
@@ -56,6 +57,7 @@ export type FollowupRun = {
verboseLevel?: VerboseLevel;
reasoningLevel?: ReasoningLevel;
elevatedLevel?: ElevatedLevel;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: {
enabled: boolean;
allowed: boolean;
+239 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { saveSessionStore } from "../../config/sessions.js";
@@ -170,3 +170,241 @@ describe("initSessionState RawBody", () => {
expect(result.triggerBodyNormalized).toBe("/status");
});
});
describe("initSessionState reset policy", () => {
it("defaults to daily reset at 4am local time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S1";
const existingSessionId = "daily-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 3, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S-edge";
const existingSessionId = "daily-edge-session";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(),
},
});
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("expires sessions when idle timeout wins over daily reset", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S2";
const existingSessionId = "idle-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("uses per-type overrides for thread sessions", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const existingSessionId = "thread-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4 },
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("detects thread sessions without thread key suffix", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:C1";
const existingSessionId = "thread-nosuffix";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("defaults to daily resets when only resetByType is configured", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S4";
const existingSessionId = "type-default-session";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
resetByType: { thread: { mode: "idle", idleMinutes: 60 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
it("keeps legacy idleMinutes behavior without reset config", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S3";
const existingSessionId = "legacy-session-id";
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
idleMinutes: 240,
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
} finally {
vi.useRealTimers();
}
});
});
+17 -4
View File
@@ -6,11 +6,14 @@ import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGERS,
deriveSessionMetaPatch,
evaluateSessionFreshness,
type GroupKeyResolution,
loadSessionStore,
resolveThreadFlag,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveGroupSessionKey,
resolveSessionFilePath,
resolveSessionKey,
@@ -105,7 +108,6 @@ export async function initSessionState(params: {
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
@@ -170,8 +172,19 @@ export async function initSessionState(params: {
sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
const now = Date.now();
const isThread = resolveThreadFlag({
sessionKey,
messageThreadId: ctx.MessageThreadId,
threadLabel: ctx.ThreadLabel,
threadStarterBody: ctx.ThreadStarterBody,
parentSessionKey: ctx.ParentSessionKey,
});
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
const freshEntry = entry
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
: false;
if (!isNewSession && freshEntry) {
sessionId = entry.sessionId;
-43
View File
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import { serveAcpGateway } from "../acp/server.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
export function registerAcpCli(program: Command) {
program
.command("acp")
.description("Run an ACP bridge backed by the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option("--session <key>", "Default session key (e.g. agent:main:main)")
.option("--session-label <label>", "Default session label to resolve")
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--verbose, -v", "Verbose logging to stderr", false)
.addHelpText(
"after",
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.clawd.bot/cli/acp")}\n`,
)
.action((opts) => {
try {
serveAcpGateway({
gatewayUrl: opts.url as string | undefined,
gatewayToken: opts.token as string | undefined,
gatewayPassword: opts.password as string | undefined,
defaultSessionKey: opts.session as string | undefined,
defaultSessionLabel: opts.sessionLabel as string | undefined,
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
verbose: Boolean(opts.verbose),
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
+1 -1
View File
@@ -26,7 +26,7 @@ vi.mock("../runtime.js", () => ({
}));
describe("cron cli", () => {
it("trims model and thinking on cron add", { timeout: 30_000 }, async () => {
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
+17 -27
View File
@@ -6,6 +6,12 @@ import { setVerbose } from "../globals.js";
import { withProgress, withProgressTotals } from "./progress.js";
import { formatErrorMessage, withManager } from "./cli-utils.js";
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
import {
resolveMemoryCacheState,
resolveMemoryFtsState,
resolveMemoryVectorState,
type Tone,
} from "../memory/status-format.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -131,6 +137,8 @@ export function registerMemoryCli(program: Command) {
const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
const colorForTone = (tone: Tone) =>
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
@@ -164,18 +172,9 @@ export function registerMemoryCli(program: Command) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
if (status.vector) {
const vectorState = status.vector.enabled
? status.vector.available
? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
? theme.success
: vectorState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
const vectorState = resolveMemoryVectorState(status.vector);
const vectorColor = colorForTone(vectorState.tone);
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
}
@@ -187,31 +186,22 @@ export function registerMemoryCli(program: Command) {
}
}
if (status.fts) {
const ftsState = status.fts.enabled
? status.fts.available
? "ready"
: "unavailable"
: "disabled";
const ftsColor =
ftsState === "ready"
? theme.success
: ftsState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
const ftsState = resolveMemoryFtsState(status.fts);
const ftsColor = colorForTone(ftsState.tone);
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
if (status.fts.error) {
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
}
}
if (status.cache) {
const cacheState = status.cache.enabled ? "enabled" : "disabled";
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
const cacheState = resolveMemoryCacheState(status.cache);
const cacheColor = colorForTone(cacheState.tone);
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`,
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
);
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
+1 -1
View File
@@ -14,7 +14,7 @@ vi.mock("../commands/models.js", async () => {
});
describe("models cli", () => {
it("registers github-copilot login command", { timeout: 15_000 }, async () => {
it("registers github-copilot login command", { timeout: 60_000 }, async () => {
const { Command } = await import("commander");
const { registerModelsCli } = await import("./models-cli.js");
+3
View File
@@ -163,6 +163,9 @@ export function registerPluginsCli(program: Command) {
if (plugin.toolNames.length > 0) {
lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
}
if (plugin.hookNames.length > 0) {
lines.push(`Hooks: ${plugin.hookNames.join(", ")}`);
}
if (plugin.gatewayMethods.length > 0) {
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
}
-2
View File
@@ -1,7 +1,6 @@
import type { Command } from "commander";
import { loadConfig } from "../../config/config.js";
import { registerPluginCliCommands } from "../../plugins/cli.js";
import { registerAcpCli } from "../acp-cli.js";
import { registerChannelsCli } from "../channels-cli.js";
import { registerCronCli } from "../cron-cli.js";
import { registerDaemonCli } from "../daemon-cli.js";
@@ -23,7 +22,6 @@ import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerLogsCli(program);
+8 -4
View File
@@ -9,9 +9,11 @@ import {
} from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
evaluateSessionFreshness,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveSessionKey,
resolveStorePath,
type SessionEntry,
@@ -38,8 +40,6 @@ export function resolveSession(opts: {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1);
const idleMs = idleMinutes * 60_000;
const explicitSessionKey = opts.sessionKey?.trim();
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, {
@@ -68,7 +68,11 @@ export function resolveSession(opts: {
}
}
const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs;
const resetType = resolveSessionResetType({ sessionKey });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
const fresh = sessionEntry
? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }).fresh
: false;
const sessionId =
opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
const isNewSession = !fresh && !opts.sessionId;
@@ -55,6 +55,7 @@ beforeEach(() => {
killed: false,
});
ensureAuthProfileStore.mockReset().mockReturnValue({ version: 1, profiles: {} });
loadClawdbotPlugins.mockReset().mockReturnValue({ plugins: [], diagnostics: [] });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
@@ -131,6 +132,7 @@ const runCommandWithTimeout = vi.fn().mockResolvedValue({
});
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const loadClawdbotPlugins = vi.fn().mockReturnValue({ plugins: [], diagnostics: [] });
const legacyReadConfigFileSnapshot = vi.fn().mockResolvedValue({
path: "/tmp/clawdbot.json",
@@ -173,9 +175,8 @@ vi.mock("../agents/skills-status.js", () => ({
}));
vi.mock("../plugins/loader.js", () => ({
loadClawdbotPlugins: () => ({ plugins: [], diagnostics: [] }),
loadClawdbotPlugins,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -322,7 +322,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
}));
describe("doctor command", () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 30_000 }, async () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", { timeout: 60_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
@@ -366,7 +366,7 @@ describe("doctor command", () => {
expect(written.routing).toBeUndefined();
});
it("migrates legacy gateway services", { timeout: 30_000 }, async () => {
it("migrates legacy gateway services", { timeout: 60_000 }, async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
+18 -21
View File
@@ -7,6 +7,12 @@ import type { RuntimeEnv } from "../runtime.js";
import { runSecurityAudit } from "../security/audit.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import {
resolveMemoryCacheSummary,
resolveMemoryFtsState,
resolveMemoryVectorState,
type Tone,
} from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary } from "./status.daemon.js";
@@ -250,33 +256,24 @@ export async function statusCommand(
parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`);
if (memory.sources?.length) parts.push(`sources ${memory.sources.join(", ")}`);
if (memoryPlugin.slot) parts.push(`plugin ${memoryPlugin.slot}`);
const colorByTone = (tone: Tone, text: string) =>
tone === "ok" ? ok(text) : tone === "warn" ? warn(text) : muted(text);
const vector = memory.vector;
parts.push(
vector?.enabled === false
? muted("vector off")
: vector?.available
? ok("vector ready")
: vector?.available === false
? warn("vector unavailable")
: muted("vector unknown"),
);
if (vector) {
const state = resolveMemoryVectorState(vector);
const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`;
parts.push(colorByTone(state.tone, label));
}
const fts = memory.fts;
if (fts) {
parts.push(
fts.enabled === false
? muted("fts off")
: fts.available
? ok("fts ready")
: warn("fts unavailable"),
);
const state = resolveMemoryFtsState(fts);
const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`;
parts.push(colorByTone(state.tone, label));
}
const cache = memory.cache;
if (cache) {
parts.push(
cache.enabled
? ok(`cache on${typeof cache.entries === "number" ? ` (${cache.entries})` : ""}`)
: muted("cache off"),
);
const summary = resolveMemoryCacheSummary(cache);
parts.push(colorByTone(summary.tone, summary.text));
}
return parts.join(" · ");
})();
+1
View File
@@ -2,6 +2,7 @@ export * from "./sessions/group.js";
export * from "./sessions/metadata.js";
export * from "./sessions/main-session.js";
export * from "./sessions/paths.js";
export * from "./sessions/reset.js";
export * from "./sessions/session-key.js";
export * from "./sessions/store.js";
export * from "./sessions/types.js";
+130
View File
@@ -0,0 +1,130 @@
import type { SessionConfig } from "../types.base.js";
import { DEFAULT_IDLE_MINUTES } from "./types.js";
export type SessionResetMode = "daily" | "idle";
export type SessionResetType = "dm" | "group" | "thread";
export type SessionResetPolicy = {
mode: SessionResetMode;
atHour: number;
idleMinutes?: number;
};
export type SessionFreshness = {
fresh: boolean;
dailyResetAt?: number;
idleExpiresAt?: number;
};
export const DEFAULT_RESET_MODE: SessionResetMode = "daily";
export const DEFAULT_RESET_AT_HOUR = 4;
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
export function isThreadSessionKey(sessionKey?: string | null): boolean {
const normalized = (sessionKey ?? "").toLowerCase();
if (!normalized) return false;
return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker));
}
export function resolveSessionResetType(params: {
sessionKey?: string | null;
isGroup?: boolean;
isThread?: boolean;
}): SessionResetType {
if (params.isThread || isThreadSessionKey(params.sessionKey)) return "thread";
if (params.isGroup) return "group";
const normalized = (params.sessionKey ?? "").toLowerCase();
if (GROUP_SESSION_MARKERS.some((marker) => normalized.includes(marker))) return "group";
return "dm";
}
export function resolveThreadFlag(params: {
sessionKey?: string | null;
messageThreadId?: string | number | null;
threadLabel?: string | null;
threadStarterBody?: string | null;
parentSessionKey?: string | null;
}): boolean {
if (params.messageThreadId != null) return true;
if (params.threadLabel?.trim()) return true;
if (params.threadStarterBody?.trim()) return true;
if (params.parentSessionKey?.trim()) return true;
return isThreadSessionKey(params.sessionKey);
}
export function resolveDailyResetAtMs(now: number, atHour: number): number {
const normalizedAtHour = normalizeResetAtHour(atHour);
const resetAt = new Date(now);
resetAt.setHours(normalizedAtHour, 0, 0, 0);
if (now < resetAt.getTime()) {
resetAt.setDate(resetAt.getDate() - 1);
}
return resetAt.getTime();
}
export function resolveSessionResetPolicy(params: {
sessionCfg?: SessionConfig;
resetType: SessionResetType;
idleMinutesOverride?: number;
}): SessionResetPolicy {
const sessionCfg = params.sessionCfg;
const baseReset = sessionCfg?.reset;
const typeReset = sessionCfg?.resetByType?.[params.resetType];
const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType);
const legacyIdleMinutes = sessionCfg?.idleMinutes;
const mode =
typeReset?.mode ??
baseReset?.mode ??
(!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE);
const atHour = normalizeResetAtHour(typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR);
const idleMinutesRaw =
params.idleMinutesOverride ??
typeReset?.idleMinutes ??
baseReset?.idleMinutes ??
legacyIdleMinutes;
let idleMinutes: number | undefined;
if (idleMinutesRaw != null) {
const normalized = Math.floor(idleMinutesRaw);
if (Number.isFinite(normalized)) {
idleMinutes = Math.max(normalized, 1);
}
} else if (mode === "idle") {
idleMinutes = DEFAULT_IDLE_MINUTES;
}
return { mode, atHour, idleMinutes };
}
export function evaluateSessionFreshness(params: {
updatedAt: number;
now: number;
policy: SessionResetPolicy;
}): SessionFreshness {
const dailyResetAt =
params.policy.mode === "daily"
? resolveDailyResetAtMs(params.now, params.policy.atHour)
: undefined;
const idleExpiresAt =
params.policy.idleMinutes != null
? params.updatedAt + params.policy.idleMinutes * 60_000
: undefined;
const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt;
const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt;
return {
fresh: !(staleDaily || staleIdle),
dailyResetAt,
idleExpiresAt,
};
}
function normalizeResetAtHour(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RESET_AT_HOUR;
const normalized = Math.floor(value);
if (!Number.isFinite(normalized)) return DEFAULT_RESET_AT_HOUR;
if (normalized < 0) return 0;
if (normalized > 23) return 23;
return normalized;
}
+4
View File
@@ -42,6 +42,10 @@ export type SessionEntry = {
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
execHost?: string;
execSecurity?: string;
execAsk?: string;
execNode?: string;
responseUsage?: "on" | "off" | "tokens" | "full";
providerOverride?: string;
modelOverride?: string;
+16
View File
@@ -55,6 +55,20 @@ export type SessionSendPolicyConfig = {
rules?: SessionSendPolicyRule[];
};
export type SessionResetMode = "daily" | "idle";
export type SessionResetConfig = {
mode?: SessionResetMode;
/** Local hour (0-23) for the daily reset boundary. */
atHour?: number;
/** Sliding idle window (minutes). When set with daily mode, whichever expires first wins. */
idleMinutes?: number;
};
export type SessionResetByTypeConfig = {
dm?: SessionResetConfig;
group?: SessionResetConfig;
thread?: SessionResetConfig;
};
export type SessionConfig = {
scope?: SessionScope;
/** DM session scoping (default: "main"). */
@@ -64,6 +78,8 @@ export type SessionConfig = {
resetTriggers?: string[];
idleMinutes?: number;
heartbeatIdleMinutes?: number;
reset?: SessionResetConfig;
resetByType?: SessionResetByTypeConfig;
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;
+18
View File
@@ -183,6 +183,24 @@ export const AgentToolsSchema = z
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
exec: z
.object({
host: z.enum(["sandbox", "gateway", "node"]).optional(),
security: z.enum(["deny", "allowlist", "full"]).optional(),
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
notifyOnExit: z.boolean().optional(),
applyPatch: z
.object({
enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
+14
View File
@@ -7,6 +7,12 @@ import {
QueueSchema,
} from "./zod-schema.core.js";
const SessionResetConfigSchema = z.object({
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
atHour: z.number().int().min(0).max(23).optional(),
idleMinutes: z.number().int().positive().optional(),
});
export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -17,6 +23,14 @@ export const SessionSchema = z
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
reset: SessionResetConfigSchema.optional(),
resetByType: z
.object({
dm: SessionResetConfigSchema.optional(),
group: SessionResetConfigSchema.optional(),
thread: SessionResetConfigSchema.optional(),
})
.optional(),
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
+1 -1
View File
@@ -33,7 +33,7 @@ beforeEach(() => {
});
describe("discord native commands", () => {
it("streams tool results for native slash commands", { timeout: 30_000 }, async () => {
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
const { ChannelType } = await import("@buape/carbon");
const { createDiscordNativeCommand } = await import("./monitor.js");
+117
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
@@ -262,6 +264,121 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
} finally {
await server.close({ reason: "test done" });
}
});
it("does not include history markers for single message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("treats developer role same as system role", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes tool output when it is the latest message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
} finally {
await server.close({ reason: "test done" });
}
});
it("returns a non-streaming OpenAI chat.completion response", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
+52 -5
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
@@ -17,6 +18,7 @@ type OpenAiHttpOptions = {
type OpenAiChatMessage = {
role?: unknown;
content?: unknown;
name?: unknown;
};
type OpenAiChatCompletionRequest = {
@@ -85,24 +87,69 @@ function buildAgentPrompt(messagesUnknown: unknown): {
const messages = asMessages(messagesUnknown);
const systemParts: string[] = [];
let lastUser = "";
const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> =
[];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const role = typeof msg.role === "string" ? msg.role.trim() : "";
const content = extractTextContent(msg.content).trim();
if (!role || !content) continue;
if (role === "system") {
if (role === "system" || role === "developer") {
systemParts.push(content);
continue;
}
if (role === "user") {
lastUser = content;
const normalizedRole = role === "function" ? "tool" : role;
if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") {
continue;
}
const name = typeof msg.name === "string" ? msg.name.trim() : "";
const sender =
normalizedRole === "assistant"
? "Assistant"
: normalizedRole === "user"
? "User"
: name
? `Tool:${name}`
: "Tool";
conversationEntries.push({
role: normalizedRole,
entry: { sender, body: content },
});
}
let message = "";
if (conversationEntries.length > 0) {
let currentIndex = -1;
for (let i = conversationEntries.length - 1; i >= 0; i -= 1) {
const entryRole = conversationEntries[i]?.role;
if (entryRole === "user" || entryRole === "tool") {
currentIndex = i;
break;
}
}
if (currentIndex < 0) currentIndex = conversationEntries.length - 1;
const currentEntry = conversationEntries[currentIndex]?.entry;
if (currentEntry) {
const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry);
if (historyEntries.length === 0) {
message = currentEntry.body;
} else {
const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`;
message = buildHistoryContextFromEntries({
entries: [...historyEntries, currentEntry],
currentMessage: formatEntry(currentEntry),
formatEntry,
});
}
}
}
return {
message: lastUser,
message,
extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
};
}
+4
View File
@@ -45,6 +45,10 @@ export const SessionsPatchParamsSchema = Type.Object(
]),
),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional(
@@ -6,7 +6,6 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { isAcpSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
@@ -386,7 +385,6 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
runId: clientRunId,
status: "started" as const,
};
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -399,7 +397,6 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
ctx.deps,
-3
View File
@@ -7,7 +7,6 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
@@ -300,7 +299,6 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -313,7 +311,6 @@ export const chatHandlers: GatewayRequestHandlers = {
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
context.deps,
@@ -333,7 +333,7 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
+2 -2
View File
@@ -26,7 +26,7 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
}
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 30_000 }, async () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const { server, ws } = await startServerWithClient();
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
@@ -129,7 +129,7 @@ describe("gateway server auth/connect", () => {
test(
"invalid connect params surface in response and close reason",
{ timeout: 15000 },
{ timeout: 60_000 },
async () => {
const { server, ws } = await startServerWithClient();
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
+27 -27
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
connectOk,
installGatewayTestHooks,
@@ -10,11 +10,27 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks();
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
afterEach(async () => {
for (const { server, ws } of servers) {
try {
ws.close();
await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
});
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { server, ws } = await startServerWithClient();
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{
@@ -40,18 +56,12 @@ describe("gateway server channels", () => {
expect(signal?.configured).toBe(false);
expect(signal?.probe).toBeUndefined();
expect(signal?.lastProbeAt).toBeNull();
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevToken;
}
});
test("channels.logout reports no session when missing", async () => {
const { server, ws } = await startServerWithClient();
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
@@ -60,14 +70,10 @@ describe("gateway server channels", () => {
expect(res.ok).toBe(true);
expect(res.payload?.channel).toBe("whatsapp");
expect(res.payload?.cleared).toBe(false);
ws.close();
await server.close();
});
test("channels.logout clears telegram bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({
channels: {
@@ -78,7 +84,9 @@ describe("gateway server channels", () => {
},
});
const { server, ws } = await startServerWithClient();
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const res = await rpcReq<{
@@ -95,13 +103,5 @@ describe("gateway server channels", () => {
expect(snap.valid).toBe(true);
expect(snap.config?.channels?.telegram?.botToken).toBeUndefined();
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevToken;
}
});
});
@@ -26,7 +26,7 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
}
describe("gateway server chat", () => {
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -105,7 +105,7 @@ describe("gateway server chat", () => {
await server.close();
});
test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
test("chat.abort cancels an in-flight chat.send", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -263,7 +263,7 @@ describe("gateway server chat", () => {
await server.close();
});
test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
test("chat.send treats /stop as an out-of-band abort", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
+25 -25
View File
@@ -1,7 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import { afterEach, describe, expect, it } from "vitest";
import {
connectOk,
@@ -12,13 +10,26 @@ import {
installGatewayTestHooks();
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
afterEach(async () => {
for (const { server, ws } of servers) {
try {
ws.close();
await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
});
describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => {
vi.useFakeTimers();
const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1);
const { server, ws } = await startServerWithClient();
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-1";
@@ -40,22 +51,14 @@ describe("gateway config.apply", () => {
);
expect(res.ok).toBe(true);
await vi.advanceTimersByTimeAsync(0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
ws.close();
await server.close();
process.off("SIGUSR1", sigusr1);
vi.useRealTimers();
const sentinel = await readRestartSentinel();
expect(sentinel?.payload.kind).toBe("config-apply");
});
it("rejects invalid raw config", async () => {
const { server, ws } = await startServerWithClient();
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-2";
@@ -74,8 +77,5 @@ describe("gateway config.apply", () => {
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
ws.close();
await server.close();
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ import {
installGatewayTestHooks();
describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 20_000 }, async () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
+1 -1
View File
@@ -46,7 +46,7 @@ describe("gateway server misc", () => {
}
});
test("send dedupes by idempotencyKey", { timeout: 20_000 }, async () => {
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
+1 -1
View File
@@ -64,7 +64,7 @@ describe("gateway server models + voicewake", () => {
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 30_000 },
{ timeout: 60_000 },
async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir);
+87 -107
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "../agents/clawdbot-tools.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
@@ -13,11 +13,25 @@ import {
installGatewayTestHooks();
const servers: Array<Awaited<ReturnType<typeof startGatewayServer>>> = [];
afterEach(async () => {
for (const server of servers) {
try {
await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
// Add small delay to ensure port is fully released by OS
await new Promise((resolve) => setTimeout(resolve, 50));
});
describe("sessions_send gateway loopback", () => {
it("returns reply when lifecycle ends before agent.wait", async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
const server = await startGatewayServer(port);
const spy = vi.mocked(agentCommand);
@@ -63,44 +77,37 @@ describe("sessions_send gateway loopback", () => {
});
});
try {
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
servers.push(server);
const result = await tool.execute("call-loopback", {
sessionKey: "main",
message: "ping",
timeoutSeconds: 5,
});
const details = result.details as {
status?: string;
reply?: string;
sessionKey?: string;
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("pong");
expect(details.sessionKey).toBe("main");
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
expect(firstCall?.lane).toBe("nested");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
const result = await tool.execute("call-loopback", {
sessionKey: "main",
message: "ping",
timeoutSeconds: 5,
});
const details = result.details as {
status?: string;
reply?: string;
sessionKey?: string;
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("pong");
expect(details.sessionKey).toBe("main");
const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
expect(firstCall?.lane).toBe("nested");
});
});
describe("sessions_send label lookup", () => {
it("finds session by label and sends message", { timeout: 15_000 }, async () => {
it("finds session by label and sends message", { timeout: 60_000 }, async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
const server = await startGatewayServer(port);
servers.push(server);
const spy = vi.mocked(agentCommand);
spy.mockImplementation(async (opts) => {
const params = opts as {
@@ -134,96 +141,69 @@ describe("sessions_send label lookup", () => {
});
});
try {
// First, create a session with a label via sessions.patch
const { callGateway } = await import("./call.js");
await callGateway({
method: "sessions.patch",
params: { key: "test-labeled-session", label: "my-test-worker" },
timeoutMs: 5000,
});
// First, create a session with a label via sessions.patch
const { callGateway } = await import("./call.js");
await callGateway({
method: "sessions.patch",
params: { key: "test-labeled-session", label: "my-test-worker" },
timeoutMs: 5000,
});
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
// Send using label instead of sessionKey
const result = await tool.execute("call-by-label", {
label: "my-test-worker",
message: "hello labeled session",
timeoutSeconds: 5,
});
const details = result.details as {
status?: string;
reply?: string;
sessionKey?: string;
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("labeled response");
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
// Send using label instead of sessionKey
const result = await tool.execute("call-by-label", {
label: "my-test-worker",
message: "hello labeled session",
timeoutSeconds: 5,
});
const details = result.details as {
status?: string;
reply?: string;
sessionKey?: string;
};
expect(details.status).toBe("ok");
expect(details.reply).toBe("labeled response");
expect(details.sessionKey).toBe("agent:main:test-labeled-session");
});
it("returns error when label not found", { timeout: 15_000 }, async () => {
it("returns error when label not found", { timeout: 60_000 }, async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
const server = await startGatewayServer(port);
servers.push(server);
try {
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const result = await tool.execute("call-missing-label", {
label: "nonexistent-label",
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("No session found with label");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
const result = await tool.execute("call-missing-label", {
label: "nonexistent-label",
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("No session found with label");
});
it("returns error when neither sessionKey nor label provided", { timeout: 15_000 }, async () => {
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port));
const server = await startGatewayServer(port);
servers.push(server);
try {
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_send");
if (!tool) throw new Error("missing sessions_send tool");
const result = await tool.execute("call-no-key", {
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Either sessionKey or label is required");
} finally {
if (prevPort === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prevPort;
}
await server.close();
}
const result = await tool.execute("call-no-key", {
message: "hello",
timeoutSeconds: 5,
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Either sessionKey or label is required");
});
});
+68
View File
@@ -30,6 +30,30 @@ function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
}
function normalizeExecHost(raw: string): "sandbox" | "gateway" | "node" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return undefined;
}
function normalizeExecSecurity(raw: string): "deny" | "allowlist" | "full" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return undefined;
}
function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized;
}
return undefined;
}
export async function applySessionsPatchToStore(params: {
cfg: ClawdbotConfig;
store: Record<string, SessionEntry>;
@@ -150,6 +174,50 @@ export async function applySessionsPatchToStore(params: {
}
}
if ("execHost" in patch) {
const raw = patch.execHost;
if (raw === null) {
delete next.execHost;
} else if (raw !== undefined) {
const normalized = normalizeExecHost(String(raw));
if (!normalized) return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
next.execHost = normalized;
}
}
if ("execSecurity" in patch) {
const raw = patch.execSecurity;
if (raw === null) {
delete next.execSecurity;
} else if (raw !== undefined) {
const normalized = normalizeExecSecurity(String(raw));
if (!normalized) return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")');
next.execSecurity = normalized;
}
}
if ("execAsk" in patch) {
const raw = patch.execAsk;
if (raw === null) {
delete next.execAsk;
} else if (raw !== undefined) {
const normalized = normalizeExecAsk(String(raw));
if (!normalized) return invalid('invalid execAsk (use "off"|"on-miss"|"always")');
next.execAsk = normalized;
}
}
if ("execNode" in patch) {
const raw = patch.execNode;
if (raw === null) {
delete next.execNode;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) return invalid("invalid execNode: empty");
next.execNode = trimmed;
}
}
if ("model" in patch) {
const raw = patch.model;
if (raw === null) {
+1
View File
@@ -39,6 +39,7 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
**Events**: `agent:bootstrap`
**What it does**: Overrides the injected SOUL content before the system prompt is built.
**Output**: No files written; swaps happen in-memory only.
**Docs**: https://docs.clawd.bot/hooks/soul-evil
**Enable**:
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: soul-evil
description: "Swap SOUL.md with SOUL_EVIL.md during a purge window or by random chance"
homepage: https://docs.clawd.bot/hooks#soul-evil
homepage: https://docs.clawd.bot/hooks/soul-evil
metadata:
{
"clawdbot":
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more