Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6842fdf4 | ||
|
|
282dbe3167 | ||
|
|
6d749b6e2c | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 |
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -956,6 +956,8 @@
|
||||
{
|
||||
"group": "Automation & Hooks",
|
||||
"pages": [
|
||||
"hooks",
|
||||
"hooks/soul-evil",
|
||||
"automation/auth-monitoring",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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**:
|
||||
|
||||
@@ -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
@@ -160,7 +160,11 @@ Example:
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
idleMinutes: 10080
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 10080
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+9
-4
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+6
-12
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+38
@@ -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");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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,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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
+41
@@ -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);
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,3 +153,4 @@ export function extractStatusDirective(body?: string): {
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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))}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" · ");
|
||||
})();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
Reference in new issue
Block a user