Compare commits

..
Author SHA1 Message Date
Peter Steinberger 52edef225b fix: tolerate trailing semicolons in browser evaluate 2026-01-03 21:37:06 +00:00
Azade 609aa4a7c5 fix(browser): avoid esbuild __name helper in evaluateViaPlaywright
When tsx/esbuild compiles arrow functions, it adds a __name helper
for debugging. This helper doesn't exist in the browser context,
causing 'ReferenceError: __name is not defined' when using
page.evaluate() with inline functions.

The fix uses new Function() constructed at runtime, which esbuild
doesn't transform, avoiding the __name injection.
2026-01-03 20:54:11 +00:00
Peter Steinberger 7bad9f3fbd fix: drop embedded sandbox wiring 2026-01-03 20:16:53 +00:00
Peter Steinberger 16e3535ac0 refactor: remove bash pty mode 2026-01-03 20:15:10 +00:00
Peter Steinberger a15cffb7de fix: stream tool summaries early and tool output 2026-01-03 21:04:40 +01:00
Peter Steinberger 03c1599544 docs(templates): add platform formatting tips (Discord embeds, tables) 2026-01-03 20:01:17 +00:00
Shadow 6464d93bbb Discord: add forwarded message handling 2026-01-03 13:56:09 -06:00
Peter Steinberger 424d31af1f docs(templates): add voice storytelling tip for sag users 2026-01-03 19:55:32 +00:00
Peter Steinberger e9d7ac8e84 feat(gateway): add config hot reload 2026-01-03 19:52:24 +00:00
Peter Steinberger fac694fc03 docs(skills): add parallel Codex orchestration learnings
- coding-agent: document --yolo flag, git worktree + tmux pattern
- tmux: add section on orchestrating coding agents in parallel

Learnings from running 5 parallel Codex sessions to analyze GitHub issues
2026-01-03 19:45:18 +00:00
Shadow 3e84b9632d Discord: handle system message types 2026-01-03 13:15:19 -06:00
Peter Steinberger ce3fd09e14 docs(faq): add alternative providers section (OpenRouter, Z.AI)
- Added OpenRouter and Z.AI setup examples
- Emphasized using latest Claude 4.5 models, not deprecated 3.x

🦞
2026-01-03 19:14:05 +00:00
Peter Steinberger 641080a0b6 fix: document macOS permission requirements 2026-01-03 20:05:22 +01:00
Jake 99c3fc1128 Scripts: Make ad-hoc fallback opt-in with stronger TCC warnings 2026-01-03 20:05:22 +01:00
Jake 8c7b2aa2d3 Scripts: Fallback to ad-hoc signing in codesign-mac-app.sh 2026-01-03 20:05:22 +01:00
28 changed files with 1154 additions and 452 deletions

No files matched your search

+5
View File
@@ -5,9 +5,11 @@
### Breaking
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
@@ -16,7 +18,9 @@
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
### Fixes
- Browser tools: tolerate trailing semicolons in evaluate expressions (#153) — thanks @azade-c.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
- Bash tool: default auto-background delay to 10s.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
@@ -47,6 +51,7 @@
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation.
- Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
+3 -3
View File
@@ -13,10 +13,10 @@ Clawdis runs shell commands through the `bash` tool and keeps long‑running tas
Key parameters:
- `command` (required)
- `yieldMs` (default 20000): auto‑background after this delay
- `yieldMs` (default 10000): auto‑background after this delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
- Need a real TTY? Use the tmux skill.
- `workdir`, `env`
Behavior:
@@ -30,7 +30,7 @@ Environment overrides:
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
Config (preferred):
- `agent.bash.backgroundMs` (default 20000)
- `agent.bash.backgroundMs` (default 10000)
- `agent.bash.timeoutSec` (default 1800)
- `agent.bash.cleanupMs` (default 1800000)
+2 -15
View File
@@ -12,18 +12,10 @@ Run shell commands in the workspace. Supports foreground + background execution
## Parameters
- `command` (required)
- `yieldMs` (default 20000): auto-background after delay
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `stdinMode` (`pipe` | `pty`):
- `pipe` (default): classic stdin/stdout/stderr pipes
- `pty`: real TTY via node-pty (merged stdout/stderr)
## TTY mode (`stdinMode: "pty"`)
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
- Output streams are merged (no separate stderr).
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
- Need a real TTY? Use the tmux skill.
## Examples
@@ -37,8 +29,3 @@ Background + poll:
{"tool":"bash","command":"npm run build","yieldMs":1000}
{"tool":"process","action":"poll","sessionId":"<id>"}
```
TTY command:
```json
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
```
+44 -2
View File
@@ -388,7 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
},
maxConcurrent: 3,
bash: {
backgroundMs: 20000,
backgroundMs: 10000,
timeoutSec: 1800,
cleanupMs: 1800000
},
@@ -427,7 +427,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
`agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 20000)
- `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
@@ -696,6 +696,48 @@ Remote client defaults (CLI):
}
```
### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`) and applies changes automatically.
Modes:
- `hybrid` (default): hot-apply safe changes; restart the Gateway for critical changes.
- `hot`: only apply hot-safe changes; log when a restart is required.
- `restart`: restart the Gateway on any config change.
- `off`: disable hot reload.
```json5
{
gateway: {
reload: {
mode: "hybrid",
debounceMs: 300
}
}
}
```
#### Hot reload matrix (files + impact)
Files watched:
- `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`)
Hot-applied (no full gateway restart):
- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted)
- `browser` (browser control server restart)
- `cron` (cron service restart + concurrency update)
- `agent.heartbeat` (heartbeat runner restart)
- `web` (WhatsApp web provider restart)
- `telegram`, `discord`, `signal`, `imessage` (provider restarts)
- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads)
Requires full Gateway restart:
- `gateway` (port/bind/auth/control UI/tailscale)
- `bridge`
- `discovery`
- `canvasHost`
- Any unknown/unsupported config path (defaults to restart for safety)
### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
+26
View File
@@ -530,6 +530,32 @@ Use `/model` to switch without restarting:
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
### Alternative providers (OpenRouter, Z.AI)?
If you don't want to use Anthropic directly, you can use alternative providers:
**OpenRouter** (pay-per-token, many models):
```json5
{
agent: {
model: "openrouter/anthropic/claude-sonnet-4",
env: { OPENROUTER_API_KEY: "sk-or-..." }
}
}
```
**Z.AI** (flat-rate plans, GLM models):
```json5
{
agent: {
model: "zai/glm-4.7",
env: { ZAI_API_KEY: "..." }
}
}
```
**Important:** Always use the latest Claude models (4.5 series). Don't use older 3.x models — they're deprecated and less capable. Check [OpenRouter models](https://openrouter.ai/models?q=claude) for exact IDs.
### Model + thinking mode issues?
Some models don't support extended thinking well:
+4
View File
@@ -22,6 +22,10 @@ pnpm clawdis gateway --force
# dev loop (auto-reload on TS changes):
pnpm gateway:watch
```
- Config hot reload watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`).
- Default mode: `gateway.reload.mode="hybrid"` (hot-apply safe changes, restart on critical).
- Hot reload uses in-process restart via **SIGUSR1** when needed.
- Disable with `gateway.reload.mode="off"`.
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`.
+7
View File
@@ -73,6 +73,13 @@ You have access to your human's stuff. That doesn't mean you *share* their stuff
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
**📝 Platform Formatting:**
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive!
When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
+2 -2
View File
@@ -18,10 +18,10 @@ Run shell commands in the workspace.
Core parameters:
- `command` (required)
- `yieldMs` (auto-background after timeout, default 20000)
- `yieldMs` (auto-background after timeout, default 10000)
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning)
- Need a real TTY? Use the tmux skill.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
-1
View File
@@ -95,7 +95,6 @@
"grammy": "^1.39.2",
"json5": "^2.2.3",
"long": "5.3.2",
"node-pty": "^1.1.0",
"playwright-core": "1.57.0",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
-10
View File
@@ -94,9 +94,6 @@ importers:
long:
specifier: 5.3.2
version: 5.3.2
node-pty:
specifier: ^1.1.0
version: 1.1.0
playwright-core:
specifier: 1.57.0
version: 1.57.0
@@ -2188,9 +2185,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'}
@@ -4834,10 +4828,6 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-wav@0.0.2:
optional: true
+52 -1
View File
@@ -41,9 +41,15 @@ process action:kill sessionId:XXX
**Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml)
### Building/Creating (use --full-auto)
### Building/Creating (use --full-auto or --yolo)
```bash
# --full-auto: sandboxed but auto-approves in workspace
bash workdir:~/project background:true command:"codex exec --full-auto \"Build a snake game with dark theme\""
# --yolo: NO sandbox, NO approvals (fastest, most dangerous)
bash workdir:~/project background:true command:"codex --yolo \"Build a snake game with dark theme\""
# Note: --yolo is a shortcut for --dangerously-bypass-approvals-and-sandbox
```
### Reviewing PRs (vanilla, no flags)
@@ -144,6 +150,51 @@ Use the tmux skill for interactive coding sessions (always, except very simple o
---
## Parallel Issue Fixing with git worktrees + tmux
For fixing multiple issues in parallel, use git worktrees (isolated branches) + tmux sessions:
```bash
# 1. Clone repo to temp location
cd /tmp && git clone git@github.com:user/repo.git repo-worktrees
cd repo-worktrees
# 2. Create worktrees for each issue (isolated branches!)
git worktree add -b fix/issue-78 /tmp/issue-78 main
git worktree add -b fix/issue-99 /tmp/issue-99 main
# 3. Set up tmux sessions
SOCKET="${TMPDIR:-/tmp}/codex-fixes.sock"
tmux -S "$SOCKET" new-session -d -s fix-78
tmux -S "$SOCKET" new-session -d -s fix-99
# 4. Launch Codex in each (after pnpm install!)
tmux -S "$SOCKET" send-keys -t fix-78 "cd /tmp/issue-78 && pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'" Enter
tmux -S "$SOCKET" send-keys -t fix-99 "cd /tmp/issue-99 && pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'" Enter
# 5. Monitor progress
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -30
tmux -S "$SOCKET" capture-pane -p -t fix-99 -S -30
# 6. Check if done (prompt returned)
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -3 | grep -q "❯" && echo "Done!"
# 7. Create PRs after fixes
cd /tmp/issue-78 && git push -u origin fix/issue-78
gh pr create --repo user/repo --head fix/issue-78 --title "fix: ..." --body "..."
# 8. Cleanup
tmux -S "$SOCKET" kill-server
git worktree remove /tmp/issue-78
git worktree remove /tmp/issue-99
```
**Why worktrees?** Each Codex works in isolated branch, no conflicts. Can run 5+ parallel fixes!
**Why tmux over bash background?** Codex is interactive — needs TTY for proper output. tmux provides persistent sessions with full history capture.
---
## ⚠️ Rules
1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
+35
View File
@@ -65,6 +65,41 @@ To monitor:
- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL.
- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH.
## Orchestrating Coding Agents (Codex, Claude Code)
tmux excels at running multiple coding agents in parallel:
```bash
SOCKET="${TMPDIR:-/tmp}/codex-army.sock"
# Create multiple sessions
for i in 1 2 3 4 5; do
tmux -S "$SOCKET" new-session -d -s "agent-$i"
done
# Launch agents in different workdirs
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
# Poll for completion (check if prompt returned)
for sess in agent-1 agent-2; do
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then
echo "$sess: DONE"
else
echo "$sess: Running..."
fi
done
# Get full output from completed session
tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500
```
**Tips:**
- Use separate git worktrees for parallel fixes (no branch conflicts)
- `pnpm install` first before running codex in fresh clones
- Check for shell prompt (`❯` or `$`) to detect completion
- Codex needs `--yolo` or `--full-auto` for non-interactive fixes
## Cleanup
- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`.
-2
View File
@@ -20,7 +20,6 @@ describe("bash process registry", () => {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 10,
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
id: "sess",
command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(),
cwd: "/tmp",
maxOutputChars: 100,
-5
View File
@@ -1,5 +1,4 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { IPty } from "node-pty";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
@@ -16,15 +15,11 @@ let jobTtlMs = clampTtl(
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type ProcessStdinMode = "pipe" | "pty";
export interface ProcessSession {
id: string;
command: string;
child?: ChildProcessWithoutNullStreams;
pty?: IPty;
pid?: number;
stdinMode: ProcessStdinMode;
startedAt: number;
cwd?: string;
maxOutputChars: number;
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
describe("bash tool pty mode", () => {
it("falls back to pipe with warning when node-pty fails to load", async () => {
vi.resetModules();
vi.doMock("node-pty", () => {
throw new Error("boom");
});
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "echo test",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("Warning: node-pty failed to load");
expect(text).toContain("falling back to pipe mode.");
vi.doUnmock("node-pty");
});
it("uses node-pty when available", async () => {
vi.resetModules();
const spawn = vi.fn(() => {
let onData: ((data: string) => void) | undefined;
let onExit:
| ((event: { exitCode: number | null; signal?: number | null }) => void)
| undefined;
const pty = {
pid: 4321,
onData: (cb: (data: string) => void) => {
onData = cb;
},
onExit: (
cb: (event: {
exitCode: number | null;
signal?: number | null;
}) => void,
) => {
onExit = cb;
},
write: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
onData?.("hello\n");
onExit?.({ exitCode: 0, signal: null });
}, 10);
return pty;
});
vi.doMock("node-pty", () => ({ spawn }));
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "ignored",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hello");
expect(text).not.toContain("Warning:");
vi.doUnmock("node-pty");
});
});
+52 -229
View File
@@ -2,10 +2,8 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
import {
addSession,
@@ -18,7 +16,6 @@ import {
listRunningSessions,
markBackgrounded,
markExited,
type ProcessStdinMode,
setJobTtlMs,
} from "./bash-process-registry.js";
import {
@@ -34,23 +31,6 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000,
150_000,
);
const DEFAULT_SHELL_PATH = "/bin/sh";
const DEFAULT_PATH =
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
async function loadPtyModule(): Promise<PtyLoadResult> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => ({ module: mod }))
.catch((error) => ({ module: null, error }));
}
return ptyModulePromise;
}
const stringEnum = (
values: readonly string[],
@@ -81,7 +61,7 @@ const bashSchema = Type.Object({
env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional(
Type.Number({
description: "Milliseconds to wait before backgrounding (default 20000)",
description: "Milliseconds to wait before backgrounding (default 10000)",
}),
),
background: Type.Optional(
@@ -92,11 +72,6 @@ const bashSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)",
}),
),
stdinMode: Type.Optional(
stringEnum(["pipe", "pty"] as const, {
description: "stdin mode (pipe or pty when node-pty is available)",
}),
),
});
export type BashToolDetails =
@@ -120,7 +95,7 @@ export function createBashTool(
): AgentTool<any, BashToolDetails> {
const defaultBackgroundMs = clampNumber(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
20_000,
10_000,
10,
120_000,
);
@@ -133,7 +108,7 @@ export function createBashTool(
name: "bash",
label: "bash",
description:
"Execute bash with background continuation. Use yieldMs/background to continue later via process tool.",
"Execute bash with background continuation. Use yieldMs/background to continue later via process tool. For real TTY mode, use the tmux skill.",
parameters: bashSchema,
execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as {
@@ -143,7 +118,6 @@ export function createBashTool(
yieldMs?: number;
background?: boolean;
timeout?: number;
stdinMode?: "pipe" | "pty";
};
if (!params.command) {
@@ -169,84 +143,18 @@ export function createBashTool(
const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env;
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe";
let stdinMode: ProcessStdinMode = requestedStdinMode;
let warning: string | null = null;
let child: ChildProcessWithoutNullStreams | undefined;
let pty: IPty | undefined;
if (stdinMode === "pty") {
const { module: ptyModule, error: ptyError } = await loadPtyModule();
if (!ptyModule) {
warning =
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
} else {
const ptyEnv = ensurePath({
...env,
TERM: env.TERM ?? DEFAULT_PTY_NAME,
} as Record<string, string>);
const ptyShell = resolveShellPath(shell, ptyEnv);
try {
pty = ptyModule.spawn(ptyShell, [...shellArgs, params.command], {
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
});
} catch (error) {
if (
ptyShell !== DEFAULT_SHELL_PATH &&
existsSync(DEFAULT_SHELL_PATH)
) {
try {
pty = ptyModule.spawn(
DEFAULT_SHELL_PATH,
[...shellArgs, params.command],
{
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
},
);
} catch (fallbackError) {
warning =
`Warning: node-pty failed to start${formatPtyError(fallbackError)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
} else {
warning =
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
}
}
}
if (stdinMode === "pipe") {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
}
if (warning) warnings.push(warning);
const child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
const session = {
id: sessionId,
command: params.command,
child,
pty,
pid: child?.pid ?? pty?.pid,
stdinMode,
pid: child?.pid,
startedAt,
cwd: workdir,
maxOutputChars: maxOutput,
@@ -309,33 +217,21 @@ export function createBashTool(
});
};
if (child) {
child.stdout.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
child.stdout.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
}
if (pty) {
pty.onData((data) => {
const str = sanitizeBinaryOutput(data);
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
}
child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
});
return new Promise<AgentToolResult<BashToolDetails>>(
(resolve, reject) => {
@@ -434,24 +330,16 @@ export function createBashTool(
);
};
if (child) {
child.once("exit", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("exit", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
}
if (pty) {
pty.onExit(({ exitCode, signal }) => {
handleExit(exitCode ?? null, signal ?? null);
});
}
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
},
);
},
@@ -747,43 +635,25 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (session.stdinMode === "pty") {
if (!session.pty || session.exited) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
session.pty.write(params.data ?? "");
if (params.eof) {
session.pty.write("\x04");
}
} else {
if (!session.child?.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child?.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
if (!session.child?.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child?.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
if (params.eof) {
session.child.stdin.end();
}
});
if (params.eof) {
session.child.stdin.end();
}
return {
content: [
@@ -908,21 +778,12 @@ export const processTool = createProcessTool();
function killSession(session: {
pid?: number;
stdinMode: ProcessStdinMode;
pty?: IPty;
child?: ChildProcessWithoutNullStreams;
}) {
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid;
const pid = session.pid ?? session.child?.pid;
if (pid) {
killProcessTree(pid);
}
if (session.stdinMode === "pty") {
try {
session.pty?.kill();
} catch {
// ignore kill failures
}
}
}
function resolveWorkdir(workdir: string, warnings: string[]) {
@@ -949,44 +810,6 @@ function safeCwd() {
}
}
function ensurePath(env: Record<string, string>) {
if (!env.PATH?.trim()) {
env.PATH = DEFAULT_PATH;
}
return env;
}
function resolveShellPath(shell: string, env: Record<string, string>) {
if (process.platform === "win32") return shell;
if (shell.includes("/") && existsSync(shell)) {
return shell;
}
const searchPath = env.PATH ?? "";
for (const segment of searchPath.split(path.delimiter)) {
if (!segment) continue;
const candidate = path.join(segment, shell);
if (existsSync(candidate)) return candidate;
}
if (existsSync(DEFAULT_SHELL_PATH)) {
return DEFAULT_SHELL_PATH;
}
return shell;
}
function formatPtyError(error: unknown) {
if (!error) return "";
if (typeof error === "string") return ` (${error})`;
if (error instanceof Error) {
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
return firstLine ? ` (${firstLine})` : "";
}
try {
return ` (${JSON.stringify(error)})`;
} catch {
return "";
}
}
function clampNumber(
value: number | undefined,
defaultValue: number,
+2 -10
View File
@@ -48,7 +48,6 @@ import {
} from "./pi-embedded-subscribe.js";
import { extractAssistantText } from "./pi-embedded-utils.js";
import { createClawdisCodingTools } from "./pi-tools.js";
import { resolveSandboxContext } from "./sandbox.js";
import {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
@@ -363,13 +362,7 @@ export async function runEmbeddedPiAgent(params: {
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
const started = Date.now();
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
});
const workspaceDir = sandbox?.workspaceDir ?? params.workspaceDir;
const resolvedWorkspace = resolveUserPath(workspaceDir);
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider =
@@ -432,7 +425,6 @@ export async function runEmbeddedPiAgent(params: {
const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash,
surface: params.surface,
sandbox,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
@@ -494,7 +486,7 @@ export async function runEmbeddedPiAgent(params: {
};
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
await session.queueMessage(text);
await session.steer(text);
},
isStreaming: () => session.isStreaming,
abort: abortRun,
+37
View File
@@ -232,4 +232,41 @@ describe("pw-tools-core", () => {
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
});
it("evaluates expressions with trailing semicolons", async () => {
const evaluate = vi.fn(
async (fn: (body: string) => unknown, body: string) => fn(body),
);
currentPage = { evaluate };
const mod = await importModule();
const result = await mod.evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
fn: "() => 42;",
});
expect(result).toBe(42);
expect(evaluate).toHaveBeenCalled();
});
it("evaluates element expressions with trailing semicolons", async () => {
const evaluate = vi.fn(
async (
fn: (el: { tagName: string }, body: string) => unknown,
body: string,
) => fn({ tagName: "DIV" }, body),
);
currentRefLocator = { evaluate };
currentPage = {};
const mod = await importModule();
const result = await mod.evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
fn: "(el) => el.tagName;",
ref: "1",
});
expect(result).toBe("DIV");
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
});
});
+27 -36
View File
@@ -207,53 +207,44 @@ export async function evaluateViaPlaywright(opts: {
}): Promise<unknown> {
const fnText = String(opts.fn ?? "").trim();
if (!fnText) throw new Error("function is required");
const fnBody = fnText.replace(/[;\s]+$/g, "");
if (!fnBody) throw new Error("function is required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
if (opts.ref) {
const locator = refLocator(page, opts.ref);
return await locator.evaluate((el, fnBody) => {
const compileRunner = (body: string) => {
const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate(element) : candidate;`;
// This intentionally evaluates user-supplied code in the browser context.
// oxlint-disable-next-line typescript-eslint/no-implied-eval
return new Function("element", inner) as (element: Element) => unknown;
};
let compiled: unknown;
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
const elementEvaluator = new Function(
"el",
"fnBody",
`
"use strict";
try {
compiled = compileRunner(fnBody);
var candidate = eval("(" + fnBody + ")");
return typeof candidate === "function" ? candidate(el) : candidate;
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "invalid expression";
throw new Error(`Invalid evaluate function: ${message}`);
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
return (compiled as (element: Element) => unknown)(el as Element);
}, fnText);
`,
) as (el: Element, fnBody: string) => unknown;
return await locator.evaluate(elementEvaluator, fnBody);
}
return await page.evaluate((fnBody) => {
const compileRunner = (body: string) => {
const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate() : candidate;`;
// This intentionally evaluates user-supplied code in the browser context.
// oxlint-disable-next-line typescript-eslint/no-implied-eval
return new Function(inner) as () => unknown;
};
let compiled: unknown;
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
const browserEvaluator = new Function(
"fnBody",
`
"use strict";
try {
compiled = compileRunner(fnBody);
var candidate = eval("(" + fnBody + ")");
return typeof candidate === "function" ? candidate() : candidate;
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "invalid expression";
throw new Error(`Invalid evaluate function: ${message}`);
throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
}
return (compiled as () => unknown)();
}, fnText);
`,
) as (fnBody: string) => unknown;
return await page.evaluate(browserEvaluator, fnBody);
}
export async function armFileUploadViaPlaywright(opts: {
+23
View File
@@ -447,6 +447,15 @@ export type GatewayRemoteConfig = {
password?: string;
};
export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid";
export type GatewayReloadConfig = {
/** Reload strategy for config changes (default: hybrid). */
mode?: GatewayReloadMode;
/** Debounce window for config reloads (ms). Default: 300. */
debounceMs?: number;
};
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number;
@@ -464,6 +473,7 @@ export type GatewayConfig = {
auth?: GatewayAuthConfig;
tailscale?: GatewayTailscaleConfig;
remote?: GatewayRemoteConfig;
reload?: GatewayReloadConfig;
};
export type SkillConfig = {
@@ -1308,6 +1318,19 @@ export const ClawdisSchema = z.object({
password: z.string().optional(),
})
.optional(),
reload: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("restart"),
z.literal("hot"),
z.literal("hybrid"),
])
.optional(),
debounceMs: z.number().int().min(0).optional(),
})
.optional(),
})
.optional(),
skills: z
+6
View File
@@ -82,6 +82,8 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agent.workspace": "Workspace",
"agent.model": "Default Model",
"ui.seamColor": "Accent Color",
@@ -100,6 +102,10 @@ const FIELD_HELP: Record<string, string> = {
"gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdis).",
"gateway.reload.mode":
'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.",
};
const FIELD_PLACEHOLDERS: Record<string, string> = {
+159 -3
View File
@@ -10,6 +10,8 @@ import {
type Guild,
type Message,
type MessageReaction,
type MessageSnapshot,
MessageType,
type PartialMessage,
type PartialMessageReaction,
Partials,
@@ -207,13 +209,26 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botId = client.user?.id;
const wasMentioned =
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
const baseText = resolveDiscordMessageText(message);
const forwardedSnapshot = resolveForwardedSnapshot(message);
const forwardedText = forwardedSnapshot
? resolveDiscordSnapshotText(forwardedSnapshot.snapshot)
: "";
const baseText = resolveDiscordMessageText(message, forwardedText);
if (shouldLogVerbose()) {
logVerbose(
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`,
);
}
if (
isGuildMessage &&
(message.type === MessageType.ChatInputCommand ||
message.type === MessageType.ContextMenuCommand)
) {
logVerbose("discord: drop channel command message");
return;
}
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: message.guild,
@@ -339,12 +354,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
}
const systemText = resolveDiscordSystemEvent(message);
if (systemText) {
enqueueSystemEvent(systemText, {
contextKey: `discord:system:${message.channelId}:${message.id}`,
});
return;
}
const media = await resolveMedia(message, mediaMaxBytes);
const text =
message.content?.trim() ??
media?.placeholder ??
message.embeds[0]?.description ??
"";
(forwardedSnapshot ? "<forwarded message>" : "");
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
@@ -391,6 +414,42 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
if (forwardedSnapshot) {
const forwarderName = message.author.tag ?? message.author.username;
const forwarder = forwarderName
? `${forwarderName} id:${message.author.id}`
: message.author.id;
const snapshotText =
resolveDiscordSnapshotText(forwardedSnapshot.snapshot) ||
"<forwarded message>";
const forwardMetaParts = [
forwardedSnapshot.messageId
? `forwarded message id: ${forwardedSnapshot.messageId}`
: null,
forwardedSnapshot.channelId
? `channel: ${forwardedSnapshot.channelId}`
: null,
forwardedSnapshot.guildId
? `guild: ${forwardedSnapshot.guildId}`
: null,
typeof forwardedSnapshot.snapshot.type === "number"
? `snapshot type: ${forwardedSnapshot.snapshot.type}`
: null,
].filter((entry): entry is string => Boolean(entry));
const forwardedBody = forwardMetaParts.length
? `${snapshotText}\n[${forwardMetaParts.join(" ")}]`
: snapshotText;
const forwardedEnvelope = formatAgentEnvelope({
surface: "Discord",
from: `Forwarded by ${forwarder}`,
timestamp:
forwardedSnapshot.snapshot.createdTimestamp ??
message.createdTimestamp ??
undefined,
body: forwardedBody,
});
combinedBody = `[Forwarded message]\n${forwardedEnvelope}\n\n${combinedBody}`;
}
const ctxPayload = {
Body: combinedBody,
@@ -830,16 +889,24 @@ function inferPlaceholder(attachment: Attachment): string {
return "<media:document>";
}
function resolveDiscordMessageText(message: Message): string {
function resolveDiscordMessageText(
message: Message,
fallbackText?: string,
): string {
const attachment = message.attachments.first();
return (
message.content?.trim() ||
(attachment ? inferPlaceholder(attachment) : "") ||
message.embeds[0]?.description ||
fallbackText?.trim() ||
""
);
}
function resolveDiscordSnapshotText(snapshot: MessageSnapshot): string {
return snapshot.content?.trim() || snapshot.embeds[0]?.description || "";
}
async function resolveReplyContext(message: Message): Promise<string | null> {
if (!message.reference?.messageId) return null;
try {
@@ -878,6 +945,95 @@ function buildGuildLabel(message: Message) {
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
function resolveDiscordSystemEvent(message: Message): string | null {
switch (message.type) {
case MessageType.ChannelPinnedMessage:
return buildDiscordSystemEvent(message, "pinned a message");
case MessageType.RecipientAdd:
return buildDiscordSystemEvent(message, "added a recipient");
case MessageType.RecipientRemove:
return buildDiscordSystemEvent(message, "removed a recipient");
case MessageType.UserJoin:
return buildDiscordSystemEvent(message, "user joined");
case MessageType.GuildBoost:
return buildDiscordSystemEvent(message, "boosted the server");
case MessageType.GuildBoostTier1:
return buildDiscordSystemEvent(
message,
"boosted the server (Tier 1 reached)",
);
case MessageType.GuildBoostTier2:
return buildDiscordSystemEvent(
message,
"boosted the server (Tier 2 reached)",
);
case MessageType.GuildBoostTier3:
return buildDiscordSystemEvent(
message,
"boosted the server (Tier 3 reached)",
);
case MessageType.ThreadCreated:
return buildDiscordSystemEvent(message, "created a thread");
case MessageType.AutoModerationAction:
return buildDiscordSystemEvent(message, "auto moderation action");
case MessageType.GuildIncidentAlertModeEnabled:
return buildDiscordSystemEvent(message, "raid protection enabled");
case MessageType.GuildIncidentAlertModeDisabled:
return buildDiscordSystemEvent(message, "raid protection disabled");
case MessageType.GuildIncidentReportRaid:
return buildDiscordSystemEvent(message, "raid reported");
case MessageType.GuildIncidentReportFalseAlarm:
return buildDiscordSystemEvent(message, "raid report marked false alarm");
case MessageType.StageStart:
return buildDiscordSystemEvent(message, "stage started");
case MessageType.StageEnd:
return buildDiscordSystemEvent(message, "stage ended");
case MessageType.StageSpeaker:
return buildDiscordSystemEvent(message, "stage speaker updated");
case MessageType.StageTopic:
return buildDiscordSystemEvent(message, "stage topic updated");
case MessageType.PollResult:
return buildDiscordSystemEvent(message, "poll results posted");
case MessageType.PurchaseNotification:
return buildDiscordSystemEvent(message, "purchase notification");
default:
return null;
}
}
function resolveForwardedSnapshot(message: Message): {
snapshot: MessageSnapshot;
messageId?: string;
channelId?: string;
guildId?: string;
} | null {
const snapshots = message.messageSnapshots;
if (!snapshots || snapshots.size === 0) return null;
const snapshot = snapshots.first();
if (!snapshot) return null;
const reference = message.reference;
return {
snapshot,
messageId: reference?.messageId ?? undefined,
channelId: reference?.channelId ?? undefined,
guildId: reference?.guildId ?? undefined,
};
}
function buildDiscordSystemEvent(message: Message, action: string) {
const channelName =
"name" in message.channel ? message.channel.name : message.channelId;
const channelType = message.channel.type as ChannelType;
const location = message.guild?.name
? `${message.guild.name} #${channelName}`
: channelType === ChannelType.GroupDM
? `Group DM #${channelName}`
: "DM";
const authorLabel = message.author?.tag ?? message.author?.username;
const actor = authorLabel ? `${authorLabel} ` : "";
return `Discord system: ${actor}${action} in ${location}`;
}
function formatDiscordReactionEmoji(
reaction: MessageReaction | PartialMessageReaction,
) {
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
buildGatewayReloadPlan,
diffConfigPaths,
resolveGatewayReloadSettings,
} from "./config-reload.js";
describe("diffConfigPaths", () => {
it("captures nested config changes", () => {
const prev = { hooks: { gmail: { account: "a" } } };
const next = { hooks: { gmail: { account: "b" } } };
const paths = diffConfigPaths(prev, next);
expect(paths).toContain("hooks.gmail.account");
});
it("captures array changes", () => {
const prev = { routing: { groupChat: { mentionPatterns: ["a"] } } };
const next = { routing: { groupChat: { mentionPatterns: ["b"] } } };
const paths = diffConfigPaths(prev, next);
expect(paths).toContain("routing.groupChat.mentionPatterns");
});
});
describe("buildGatewayReloadPlan", () => {
it("marks gateway changes as restart required", () => {
const plan = buildGatewayReloadPlan(["gateway.port"]);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toContain("gateway.port");
});
it("restarts the Gmail watcher for hooks.gmail changes", () => {
const plan = buildGatewayReloadPlan(["hooks.gmail.account"]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartGmailWatcher).toBe(true);
expect(plan.reloadHooks).toBe(true);
});
it("restarts providers for web/telegram changes", () => {
const plan = buildGatewayReloadPlan(["web.enabled", "telegram.botToken"]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartProviders.has("whatsapp")).toBe(true);
expect(plan.restartProviders.has("telegram")).toBe(true);
});
it("treats gateway.remote as no-op", () => {
const plan = buildGatewayReloadPlan(["gateway.remote.url"]);
expect(plan.restartGateway).toBe(false);
expect(plan.noopPaths).toContain("gateway.remote.url");
});
it("defaults unknown paths to restart", () => {
const plan = buildGatewayReloadPlan(["unknownField"]);
expect(plan.restartGateway).toBe(true);
});
});
describe("resolveGatewayReloadSettings", () => {
it("uses defaults when unset", () => {
const settings = resolveGatewayReloadSettings({});
expect(settings.mode).toBe("hybrid");
expect(settings.debounceMs).toBe(300);
});
});
+365
View File
@@ -0,0 +1,365 @@
import chokidar from "chokidar";
import type {
ClawdisConfig,
ConfigFileSnapshot,
GatewayReloadMode,
} from "../config/config.js";
export type GatewayReloadSettings = {
mode: GatewayReloadMode;
debounceMs: number;
};
export type ProviderKind =
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
export type GatewayReloadPlan = {
changedPaths: string[];
restartGateway: boolean;
restartReasons: string[];
hotReasons: string[];
reloadHooks: boolean;
restartGmailWatcher: boolean;
restartBrowserControl: boolean;
restartCron: boolean;
restartHeartbeat: boolean;
restartProviders: Set<ProviderKind>;
noopPaths: string[];
};
type ReloadRule = {
prefix: string;
kind: "restart" | "hot" | "none";
actions?: ReloadAction[];
};
type ReloadAction =
| "reload-hooks"
| "restart-gmail-watcher"
| "restart-browser-control"
| "restart-cron"
| "restart-heartbeat"
| "restart-provider:whatsapp"
| "restart-provider:telegram"
| "restart-provider:discord"
| "restart-provider:signal"
| "restart-provider:imessage";
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
mode: "hybrid",
debounceMs: 300,
};
const RELOAD_RULES: ReloadRule[] = [
{ prefix: "gateway.remote", kind: "none" },
{ prefix: "gateway.reload", kind: "none" },
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{
prefix: "browser",
kind: "hot",
actions: ["restart-browser-control"],
},
{ prefix: "web", kind: "hot", actions: ["restart-provider:whatsapp"] },
{ prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] },
{ prefix: "discord", kind: "hot", actions: ["restart-provider:discord"] },
{ prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] },
{ prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] },
{ prefix: "identity", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
{ prefix: "models", kind: "none" },
{ prefix: "agent", kind: "none" },
{ prefix: "routing", kind: "none" },
{ prefix: "messages", kind: "none" },
{ prefix: "session", kind: "none" },
{ prefix: "whatsapp", kind: "none" },
{ prefix: "talk", kind: "none" },
{ prefix: "skills", kind: "none" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "bridge", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
{ prefix: "canvasHost", kind: "restart" },
];
function matchRule(path: string): ReloadRule | null {
for (const rule of RELOAD_RULES) {
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) return rule;
}
return null;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]",
);
}
export function diffConfigPaths(
prev: unknown,
next: unknown,
prefix = "",
): string[] {
if (prev === next) return [];
if (isPlainObject(prev) && isPlainObject(next)) {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
const paths: string[] = [];
for (const key of keys) {
const prevValue = prev[key];
const nextValue = next[key];
if (prevValue === undefined && nextValue === undefined) continue;
const childPrefix = prefix ? `${prefix}.${key}` : key;
const childPaths = diffConfigPaths(prevValue, nextValue, childPrefix);
if (childPaths.length > 0) {
paths.push(...childPaths);
}
}
return paths;
}
if (Array.isArray(prev) && Array.isArray(next)) {
if (
prev.length === next.length &&
prev.every((val, idx) => val === next[idx])
) {
return [];
}
}
return [prefix || "<root>"];
}
export function resolveGatewayReloadSettings(
cfg: ClawdisConfig,
): GatewayReloadSettings {
const rawMode = cfg.gateway?.reload?.mode;
const mode =
rawMode === "off" ||
rawMode === "restart" ||
rawMode === "hot" ||
rawMode === "hybrid"
? rawMode
: DEFAULT_RELOAD_SETTINGS.mode;
const debounceRaw = cfg.gateway?.reload?.debounceMs;
const debounceMs =
typeof debounceRaw === "number" && Number.isFinite(debounceRaw)
? Math.max(0, Math.floor(debounceRaw))
: DEFAULT_RELOAD_SETTINGS.debounceMs;
return { mode, debounceMs };
}
export function buildGatewayReloadPlan(
changedPaths: string[],
): GatewayReloadPlan {
const plan: GatewayReloadPlan = {
changedPaths,
restartGateway: false,
restartReasons: [],
hotReasons: [],
reloadHooks: false,
restartGmailWatcher: false,
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartProviders: new Set(),
noopPaths: [],
};
const applyAction = (action: ReloadAction) => {
switch (action) {
case "reload-hooks":
plan.reloadHooks = true;
break;
case "restart-gmail-watcher":
plan.restartGmailWatcher = true;
break;
case "restart-browser-control":
plan.restartBrowserControl = true;
break;
case "restart-cron":
plan.restartCron = true;
break;
case "restart-heartbeat":
plan.restartHeartbeat = true;
break;
case "restart-provider:whatsapp":
plan.restartProviders.add("whatsapp");
break;
case "restart-provider:telegram":
plan.restartProviders.add("telegram");
break;
case "restart-provider:discord":
plan.restartProviders.add("discord");
break;
case "restart-provider:signal":
plan.restartProviders.add("signal");
break;
case "restart-provider:imessage":
plan.restartProviders.add("imessage");
break;
default:
break;
}
};
for (const path of changedPaths) {
const rule = matchRule(path);
if (!rule) {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "restart") {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "none") {
plan.noopPaths.push(path);
continue;
}
plan.hotReasons.push(path);
for (const action of rule.actions ?? []) {
applyAction(action);
}
}
if (plan.restartGmailWatcher) {
plan.reloadHooks = true;
}
return plan;
}
export type GatewayConfigReloader = {
stop: () => Promise<void>;
};
export function startGatewayConfigReloader(opts: {
initialConfig: ClawdisConfig;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (
plan: GatewayReloadPlan,
nextConfig: ClawdisConfig,
) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: ClawdisConfig) => void;
log: {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
watchPath: string;
}): GatewayConfigReloader {
let currentConfig = opts.initialConfig;
let settings = resolveGatewayReloadSettings(currentConfig);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pending = false;
let running = false;
let stopped = false;
let restartQueued = false;
const schedule = () => {
if (stopped) return;
if (debounceTimer) clearTimeout(debounceTimer);
const wait = settings.debounceMs;
debounceTimer = setTimeout(() => {
void runReload();
}, wait);
};
const runReload = async () => {
if (stopped) return;
if (running) {
pending = true;
return;
}
running = true;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
try {
const snapshot = await opts.readSnapshot();
if (!snapshot.valid) {
const issues = snapshot.issues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(", ");
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
return;
}
const nextConfig = snapshot.config;
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
currentConfig = nextConfig;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) return;
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
return;
}
if (settings.mode === "restart") {
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
}
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", ",
)})`,
);
return;
}
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
}
return;
}
await opts.onHotReload(plan, nextConfig);
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
} finally {
running = false;
if (pending) {
pending = false;
schedule();
}
}
};
const watcher = chokidar.watch(opts.watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
});
watcher.on("add", schedule);
watcher.on("change", schedule);
watcher.on("unlink", schedule);
watcher.on("error", (err) => {
opts.log.warn(`config watcher error: ${String(err)}`);
});
return {
stop: async () => {
stopped = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
await watcher.close().catch(() => {});
},
};
}
+3 -2
View File
@@ -58,14 +58,14 @@ export type HooksRequestHandler = (
export function createHooksRequestHandler(
opts: {
hooksConfig: HooksConfigResolved | null;
getHooksConfig: () => HooksConfigResolved | null;
bindHost: string;
port: number;
logHooks: SubsystemLogger;
} & HookDispatchers,
): HooksRequestHandler {
const {
hooksConfig,
getHooksConfig,
bindHost,
port,
logHooks,
@@ -73,6 +73,7 @@ export function createHooksRequestHandler(
dispatchWakeHook,
} = opts;
return async (req, res) => {
const hooksConfig = getHooksConfig();
if (!hooksConfig) return false;
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
const basePath = hooksConfig.basePath;
+3 -1
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "vitest";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import {
connectOk,
installGatewayTestHooks,
@@ -64,6 +63,9 @@ describe("gateway server providers", () => {
test("telegram.logout clears bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { readConfigFileSnapshot, writeConfigFile } = await import(
"../config/config.js"
);
await writeConfigFile({
telegram: {
botToken: "123:abc",
+231 -58
View File
@@ -86,6 +86,11 @@ import {
authorizeGatewayConnect,
type ResolvedGatewayAuth,
} from "./auth.js";
import {
type GatewayReloadPlan,
type ProviderKind,
startGatewayConfigReloader,
} from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js";
import { resolveHooksConfig } from "./hooks.js";
import {
@@ -94,7 +99,11 @@ import {
resolveGatewayBindHost,
} from "./net.js";
import { createBridgeHandlers } from "./server-bridge.js";
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
import {
type BridgeListConnectedFn,
type BridgeSendEventFn,
createBridgeSubscriptionManager,
} from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import {
@@ -133,6 +142,7 @@ const logProviders = log.child("providers");
const logBrowser = log.child("browser");
const logHealth = log.child("health");
const logCron = log.child("cron");
const logReload = log.child("reload");
const logHooks = log.child("hooks");
const logWsControl = log.child("ws");
const logWhatsApp = logProviders.child("whatsapp");
@@ -408,7 +418,7 @@ export async function startGatewayServer(
password,
allowTailscale,
};
const hooksConfig = resolveHooksConfig(cfgAtStart);
let hooksConfig = resolveHooksConfig(cfgAtStart);
const canvasHostEnabled =
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
cfgAtStart.canvasHost?.enabled !== false;
@@ -554,7 +564,7 @@ export async function startGatewayServer(
}
const handleHooksRequest = createHooksRequestHandler({
hooksConfig,
getHooksConfig: () => hooksConfig,
bindHost,
port,
logHooks,
@@ -650,57 +660,61 @@ export async function startGatewayServer(
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1);
const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store);
const cronLogger = getChildLogger({
module: "cron",
storePath: cronStorePath,
});
const deps = createDefaultDeps();
const cronEnabled =
process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled !== false;
const cron = new CronService({
storePath: cronStorePath,
cronEnabled,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: async ({ job, message }) => {
const cfg = loadConfig();
return await runCronIsolatedAgentTurn({
cfg,
deps,
job,
message,
sessionKey: `cron:${job.id}`,
lane: "cron",
});
},
log: cronLogger,
onEvent: (evt) => {
broadcast("cron", evt, { dropIfSlow: true });
if (evt.action === "finished") {
const logPath = resolveCronRunLogPath({
storePath: cronStorePath,
jobId: evt.jobId,
const buildCronService = (cfg: ReturnType<typeof loadConfig>) => {
const storePath = resolveCronStorePath(cfg.cron?.store);
const cronEnabled =
process.env.CLAWDIS_SKIP_CRON !== "1" && cfg.cron?.enabled !== false;
const cron = new CronService({
storePath,
cronEnabled,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: async ({ job, message }) => {
const runtimeConfig = loadConfig();
return await runCronIsolatedAgentTurn({
cfg: runtimeConfig,
deps,
job,
message,
sessionKey: `cron:${job.id}`,
lane: "cron",
});
void appendCronRunLog(logPath, {
ts: Date.now(),
jobId: evt.jobId,
action: "finished",
status: evt.status,
error: evt.error,
summary: evt.summary,
runAtMs: evt.runAtMs,
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
}).catch((err) => {
cronLogger.warn(
{ err: String(err), logPath },
"cron: run log append failed",
);
});
}
},
});
},
log: getChildLogger({ module: "cron", storePath }),
onEvent: (evt) => {
broadcast("cron", evt, { dropIfSlow: true });
if (evt.action === "finished") {
const logPath = resolveCronRunLogPath({
storePath,
jobId: evt.jobId,
});
void appendCronRunLog(logPath, {
ts: Date.now(),
jobId: evt.jobId,
action: "finished",
status: evt.status,
error: evt.error,
summary: evt.summary,
runAtMs: evt.runAtMs,
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
}).catch((err) => {
cronLogger.warn(
{ err: String(err), logPath },
"cron: run log append failed",
);
});
}
},
});
return { cron, storePath, cronEnabled };
};
let { cron, storePath: cronStorePath } = buildCronService(cfgAtStart);
const providerManager = createProviderManager({
loadConfig,
@@ -719,6 +733,10 @@ export async function startGatewayServer(
getRuntimeSnapshot,
startProviders,
startWhatsAppProvider,
startTelegramProvider,
startDiscordProvider,
startSignalProvider,
startIMessageProvider,
stopWhatsAppProvider,
stopTelegramProvider,
stopDiscordProvider,
@@ -848,6 +866,11 @@ export async function startGatewayServer(
const bridgeSubscribe = bridgeSubscriptions.subscribe;
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
bridge?.sendEvent(opts);
};
const bridgeListConnected: BridgeListConnectedFn = () =>
bridge?.listConnected() ?? [];
const bridgeSendToSession = (
sessionKey: string,
event: string,
@@ -857,20 +880,16 @@ export async function startGatewayServer(
sessionKey,
event,
payload,
bridge ? (opts) => bridge.sendEvent(opts) : undefined,
bridgeSendEvent,
);
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllSubscribed(
event,
payload,
bridge ? (opts) => bridge.sendEvent(opts) : undefined,
);
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllConnected(
event,
payload,
bridge ? () => bridge.listConnected() : undefined,
bridge ? (opts) => bridge.sendEvent(opts) : undefined,
bridgeListConnected,
bridgeSendEvent,
);
const broadcastVoiceWakeChanged = (triggers: string[]) => {
@@ -1122,7 +1141,7 @@ export async function startGatewayServer(
broadcast("heartbeat", evt, { dropIfSlow: true });
});
const heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
void cron
.start()
@@ -1585,6 +1604,159 @@ export async function startGatewayServer(
logProviders.info("skipping provider start (CLAWDIS_SKIP_PROVIDERS=1)");
}
const applyHotReload = async (
plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>,
) => {
if (plan.reloadHooks) {
try {
hooksConfig = resolveHooksConfig(nextConfig);
} catch (err) {
logHooks.warn(`hooks config reload failed: ${String(err)}`);
}
}
if (plan.restartHeartbeat) {
heartbeatRunner.stop();
heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig });
}
if (plan.restartCron) {
cron.stop();
const next = buildCronService(nextConfig);
cron = next.cron;
cronStorePath = next.storePath;
void cron
.start()
.catch((err) => logCron.error(`failed to start: ${String(err)}`));
}
if (plan.restartBrowserControl) {
if (browserControl) {
await browserControl.stop().catch(() => {});
}
try {
browserControl = await startBrowserControlServerIfEnabled();
} catch (err) {
logBrowser.error(`server failed to start: ${String(err)}`);
}
}
if (plan.restartGmailWatcher) {
await stopGmailWatcher().catch(() => {});
if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") {
try {
const gmailResult = await startGmailWatcher(nextConfig);
if (gmailResult.started) {
logHooks.info("gmail watcher started");
} else if (
gmailResult.reason &&
gmailResult.reason !== "hooks not enabled" &&
gmailResult.reason !== "no gmail account configured"
) {
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
}
} catch (err) {
logHooks.error(`gmail watcher failed to start: ${String(err)}`);
}
} else {
logHooks.info(
"skipping gmail watcher restart (CLAWDIS_SKIP_GMAIL_WATCHER=1)",
);
}
}
if (plan.restartProviders.size > 0) {
if (process.env.CLAWDIS_SKIP_PROVIDERS === "1") {
logProviders.info(
"skipping provider reload (CLAWDIS_SKIP_PROVIDERS=1)",
);
} else {
const restartProvider = async (
name: ProviderKind,
stop: () => Promise<void>,
start: () => Promise<void>,
) => {
logProviders.info(`restarting ${name} provider`);
await stop();
await start();
};
if (plan.restartProviders.has("whatsapp")) {
await restartProvider(
"whatsapp",
stopWhatsAppProvider,
startWhatsAppProvider,
);
}
if (plan.restartProviders.has("telegram")) {
await restartProvider(
"telegram",
stopTelegramProvider,
startTelegramProvider,
);
}
if (plan.restartProviders.has("discord")) {
await restartProvider(
"discord",
stopDiscordProvider,
startDiscordProvider,
);
}
if (plan.restartProviders.has("signal")) {
await restartProvider(
"signal",
stopSignalProvider,
startSignalProvider,
);
}
if (plan.restartProviders.has("imessage")) {
await restartProvider(
"imessage",
stopIMessageProvider,
startIMessageProvider,
);
}
}
}
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1);
if (plan.hotReasons.length > 0) {
logReload.info(
`config hot reload applied (${plan.hotReasons.join(", ")})`,
);
}
};
const requestGatewayRestart = (
plan: GatewayReloadPlan,
_nextConfig: ReturnType<typeof loadConfig>,
) => {
const reasons = plan.restartReasons.length
? plan.restartReasons.join(", ")
: plan.changedPaths.join(", ");
logReload.warn(`config change requires gateway restart (${reasons})`);
if (process.listenerCount("SIGUSR1") === 0) {
logReload.warn("no SIGUSR1 listener found; restart skipped");
return;
}
process.emit("SIGUSR1");
};
const configReloader = startGatewayConfigReloader({
initialConfig: cfgAtStart,
readSnapshot: readConfigFileSnapshot,
onHotReload: applyHotReload,
onRestart: requestGatewayRestart,
log: {
info: (msg) => logReload.info(msg),
warn: (msg) => logReload.warn(msg),
error: (msg) => logReload.error(msg),
},
watchPath: CONFIG_PATH_CLAWDIS,
});
return {
close: async (opts) => {
const reasonRaw =
@@ -1664,6 +1836,7 @@ export async function startGatewayServer(
}
}
clients.clear();
await configReloader.stop().catch(() => {});
if (browserControl) {
await browserControl.stop().catch(() => {});
}
+3 -4
View File
@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws";
import { agentCommand } from "../commands/agent.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js";
@@ -64,6 +63,7 @@ const hoisted = vi.hoisted(() => ({
}>,
},
cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })),
agentCommand: vi.fn().mockResolvedValue(undefined),
testIsNixMode: { value: false },
sessionStoreSaveDelayMs: { value: 0 },
}));
@@ -75,6 +75,7 @@ export const bridgeSendEvent = hoisted.bridgeSendEvent;
export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand = hoisted.agentCommand;
export const testState = {
sessionStorePath: undefined as string | undefined,
@@ -290,7 +291,7 @@ vi.mock("../web/outbound.js", () => ({
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn().mockResolvedValue(undefined),
agentCommand,
}));
process.env.CLAWDIS_SKIP_PROVIDERS = "1";
@@ -509,5 +510,3 @@ export async function waitForSystemEvent(timeoutMs = 2000) {
}
throw new Error("timeout waiting for system event");
}
export { agentCommand };