Compare commits

..
Author SHA1 Message Date
Peter Steinberger be4435e7cf fix: track connection time for watchdog (fixes #99) 2026-01-03 19:55:45 +00: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
29 changed files with 1087 additions and 459 deletions

No files matched your search

+2 -2
View File
@@ -8,6 +8,7 @@
### 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.
@@ -23,11 +24,9 @@
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Agent tools: emit verbose tool summaries at tool start (no debounce).
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
@@ -47,6 +46,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.
-6
View File
@@ -14,10 +14,6 @@ You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as
Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files.
If `agent.sandbox` is enabled, non-main sessions can override this with
per-session workspaces under `agent.sandbox.workspaceRoot` (see
`docs/configuration.md`).
## Bootstrap files (injected)
Inside `agent.workspace`, CLAWDIS expects these user-editable files:
@@ -89,8 +85,6 @@ via `agent.blockStreamingDefault: "off"` if you only want the final response.
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
Control soft block chunking with `agent.blockStreamingChunk` (defaults to
800–1200 chars; prefers paragraph breaks, then newlines; sentences last).
Verbose tool summaries are emitted at tool start (no debounce); Control UI
streams tool output via agent events when available.
## Configuration (minimal)
+42
View File
@@ -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:
-1
View File
@@ -20,7 +20,6 @@ The dashboard settings panel lets you store a token; passwords are not persisted
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`providers.status`, `web.login.*`, `config.set`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
+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`.
+2
View File
@@ -73,6 +73,8 @@ 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.
## 💓 Heartbeats - Be Proactive!
When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
-1
View File
@@ -13,7 +13,6 @@ The Gateway serves a small **browser Control UI** (Vite + Lit) from the same por
The UI talks directly to the Gateway WS and supports:
- Chat (`chat.history`, `chat.send`, `chat.abort`)
- Chat tool cards (agent tool events)
- Connections (provider status, WhatsApp QR, Telegram config)
- Instances (`system-presence`)
- Sessions (`sessions.list`, `sessions.patch`)
+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"`.
+3 -9
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 = {
@@ -505,6 +497,7 @@ export async function runEmbeddedPiAgent(params: {
assistantTexts,
toolMetas,
unsubscribe,
flush: flushToolDebouncer,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
@@ -578,6 +571,7 @@ export async function runEmbeddedPiAgent(params: {
abortWarnTimer = undefined;
}
unsubscribe();
flushToolDebouncer();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
}
-103
View File
@@ -630,107 +630,4 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise;
expect(resolved).toBe(true);
});
it("emits tool summaries at tool start when verbose is on", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-1",
args: { path: "/tmp/a.txt" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("/tmp/a.txt");
handler?.({
type: "tool_execution_end",
toolName: "read",
toolCallId: "tool-1",
isError: false,
result: "ok",
});
expect(onToolResult).toHaveBeenCalledTimes(1);
});
it("skips tool summaries when shouldEmitToolResult is false", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-tool-off",
shouldEmitToolResult: () => false,
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-2",
args: { path: "/tmp/b.txt" },
});
expect(onToolResult).not.toHaveBeenCalled();
});
it("emits tool summaries when shouldEmitToolResult overrides verbose", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-tool-override",
verboseLevel: "off",
shouldEmitToolResult: () => true,
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-3",
args: { path: "/tmp/c.txt" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
});
});
+37 -32
View File
@@ -2,7 +2,10 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import {
createToolDebouncer,
formatToolAggregate,
} from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { defaultRuntime } from "../runtime.js";
@@ -110,7 +113,6 @@ export function subscribeEmbeddedPiSession(params: {
const assistantTexts: string[] = [];
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>();
const toolSummaryById = new Set<string>();
const blockReplyBreak = params.blockReplyBreak ?? "text_end";
let deltaBuffer = "";
let blockBuffer = "";
@@ -174,25 +176,17 @@ export function subscribeEmbeddedPiSession(params: {
return afterStart.slice(0, endIndex);
};
const toolDebouncer = createToolDebouncer((toolName, metas) => {
if (!params.onPartialReply) return;
const text = formatToolAggregate(toolName, metas);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
void params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
});
const blockChunking = params.blockReplyChunking;
const shouldEmitToolResult = () =>
typeof params.shouldEmitToolResult === "function"
? params.shouldEmitToolResult()
: params.verboseLevel === "on";
const emitToolSummary = (toolName?: string, meta?: string) => {
if (!params.onToolResult) return;
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
try {
void params.onToolResult({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
} catch {
// ignore tool result delivery failures
}
};
const findSentenceBreak = (window: string, minChars: number): number => {
if (!window) return -1;
@@ -304,12 +298,12 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts.length = 0;
toolMetas.length = 0;
toolMetaById.clear();
toolSummaryById.clear();
deltaBuffer = "";
blockBuffer = "";
lastStreamedAssistant = undefined;
lastBlockReplyText = undefined;
assistantTextBaseline = 0;
toolDebouncer.flush();
};
const unsubscribe = params.session.subscribe(
@@ -342,15 +336,6 @@ export function subscribeEmbeddedPiSession(params: {
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
});
if (
params.onToolResult &&
shouldEmitToolResult() &&
!toolSummaryById.has(toolCallId)
) {
toolSummaryById.add(toolCallId);
emitToolSummary(toolName, meta);
}
}
if (evt.type === "tool_execution_update") {
@@ -397,8 +382,7 @@ export function subscribeEmbeddedPiSession(params: {
const sanitizedResult = sanitizeToolResult(result);
const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta });
toolMetaById.delete(toolCallId);
toolSummaryById.delete(toolCallId);
toolDebouncer.push(toolName, meta);
emitAgentEvent({
runId: params.runId,
@@ -422,6 +406,25 @@ export function subscribeEmbeddedPiSession(params: {
isError,
},
});
const emitToolResult =
typeof params.shouldEmitToolResult === "function"
? params.shouldEmitToolResult()
: params.verboseLevel === "on";
if (emitToolResult && params.onToolResult) {
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
try {
void params.onToolResult({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
} catch {
// ignore tool result delivery failures
}
}
}
}
if (evt.type === "message_update") {
@@ -623,6 +626,7 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_end") {
defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`);
toolDebouncer.flush();
if (pendingCompactionRetry > 0) {
resolveCompactionRetry();
} else {
@@ -636,6 +640,7 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts,
toolMetas,
unsubscribe,
flush: () => toolDebouncer.flush(),
waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise();
+35
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createToolDebouncer,
formatToolAggregate,
formatToolPrefix,
shortenMeta,
@@ -47,3 +48,37 @@ describe("tool meta formatting", () => {
expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 x: ~/a.txt");
});
});
describe("tool meta debouncer", () => {
it("flushes on timer and when tool changes", () => {
vi.useFakeTimers();
try {
const calls: Array<{ tool: string | undefined; metas: string[] }> = [];
const d = createToolDebouncer((tool, metas) => {
calls.push({ tool, metas });
}, 50);
d.push("a", "/tmp/1");
d.push("a", "/tmp/2");
expect(calls).toHaveLength(0);
vi.advanceTimersByTime(60);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
tool: "a",
metas: ["/tmp/1", "/tmp/2"],
});
d.push("a", "x");
d.push("b", "y"); // tool change flushes immediately
expect(calls).toHaveLength(2);
expect(calls[1]).toMatchObject({ tool: "a", metas: ["x"] });
vi.advanceTimersByTime(60);
expect(calls).toHaveLength(3);
expect(calls[2]).toMatchObject({ tool: "b", metas: ["y"] });
} finally {
vi.useRealTimers();
}
});
});
+33
View File
@@ -4,6 +4,9 @@ import {
} from "../agents/tool-display.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
export const TOOL_RESULT_DEBOUNCE_MS = 500;
export const TOOL_RESULT_FLUSH_COUNT = 5;
export function shortenPath(p: string): string {
return shortenHomePath(p);
}
@@ -74,3 +77,33 @@ function isPathLike(value: string): boolean {
if (value.includes("&&") || value.includes("||")) return false;
return /^~?(\/[^\s]+)+$/.test(value);
}
export function createToolDebouncer(
onFlush: (toolName: string | undefined, metas: string[]) => void,
windowMs = TOOL_RESULT_DEBOUNCE_MS,
) {
let pendingTool: string | undefined;
let pendingMetas: string[] = [];
let timer: NodeJS.Timeout | null = null;
const flush = () => {
if (!pendingTool && pendingMetas.length === 0) return;
onFlush(pendingTool, pendingMetas);
pendingTool = undefined;
pendingMetas = [];
if (timer) {
clearTimeout(timer);
timer = null;
}
};
const push = (toolName?: string, meta?: string) => {
if (pendingTool && toolName && pendingTool !== toolName) flush();
if (!pendingTool) pendingTool = toolName;
if (meta) pendingMetas.push(meta);
if (timer) clearTimeout(timer);
timer = setTimeout(flush, windowMs);
};
return { push, flush };
}
+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> = {
+88
View File
@@ -10,6 +10,7 @@ import {
type Guild,
type Message,
type MessageReaction,
MessageType,
type PartialMessage,
type PartialMessageReaction,
Partials,
@@ -214,6 +215,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
);
}
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,6 +349,14 @@ 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() ??
@@ -878,6 +896,76 @@ 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 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(() => {});
},
};
}
+5 -8
View File
@@ -186,18 +186,12 @@ export function createAgentEventHandler({
};
return (evt: AgentEventPayload) => {
const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey =
chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
// Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.seq !== last + 1) {
broadcast("agent", {
runId: evt.runId,
stream: "error",
ts: Date.now(),
sessionKey,
data: {
reason: "seq gap",
expected: last + 1,
@@ -206,15 +200,18 @@ export function createAgentEventHandler({
});
}
agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", agentPayload);
broadcast("agent", evt);
const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey =
chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
const jobState =
evt.stream === "job" && typeof evt.data?.state === "string"
? (evt.data.state as "done" | "error" | string)
: null;
if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", agentPayload);
bridgeSendToSession(sessionKey, "agent", evt);
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const clientRunId = chatLink?.clientRunId ?? evt.runId;
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
+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;
-39
View File
@@ -478,43 +478,4 @@ describe("gateway server agent", () => {
ws.close();
await server.close();
});
test("agent events include sessionKey in agent payloads", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
name: "webchat",
version: "1.0.0",
platform: "test",
mode: "webchat",
},
});
registerAgentRunContext("run-tool-1", { sessionKey: "main" });
const agentEvtP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "run-tool-1",
8000,
);
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
const evt = await agentEvtP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
ws.close();
await server.close();
});
});
+218 -49
View File
@@ -96,6 +96,11 @@ import {
import { createBridgeHandlers } from "./server-bridge.js";
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import {
startGatewayConfigReloader,
type GatewayReloadPlan,
type ProviderKind,
} from "./config-reload.js";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import {
DEDUPE_MAX,
@@ -133,6 +138,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 +414,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 +560,7 @@ export async function startGatewayServer(
}
const handleHooksRequest = createHooksRequestHandler({
hooksConfig,
getHooksConfig: () => hooksConfig,
bindHost,
port,
logHooks,
@@ -650,57 +656,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 +729,10 @@ export async function startGatewayServer(
getRuntimeSnapshot,
startProviders,
startWhatsAppProvider,
startTelegramProvider,
startDiscordProvider,
startSignalProvider,
startIMessageProvider,
stopWhatsAppProvider,
stopTelegramProvider,
stopDiscordProvider,
@@ -1122,7 +1136,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 +1599,160 @@ 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 +1832,7 @@ export async function startGatewayServer(
}
}
clients.clear();
await configReloader.stop().catch(() => {});
if (browserControl) {
await browserControl.stop().catch(() => {});
}
-1
View File
@@ -11,7 +11,6 @@ export type AgentEventPayload = {
stream: AgentEventStream;
ts: number;
data: Record<string, unknown>;
sessionKey?: string;
};
export type AgentRunContext = {
+36 -28
View File
@@ -921,6 +921,7 @@ export async function monitorWebProvider(
let heartbeat: NodeJS.Timeout | null = null;
let watchdogTimer: NodeJS.Timeout | null = null;
let lastMessageAt: number | null = null;
let connectionEstablishedAt: number | null = null;
let handledMessages = 0;
let _lastInboundMsg: WebInboundMsg | null = null;
@@ -1388,7 +1389,8 @@ export async function monitorWebProvider(
});
status.connected = true;
status.lastConnectedAt = Date.now();
connectionEstablishedAt = Date.now();
status.lastConnectedAt = connectionEstablishedAt;
status.lastEventAt = status.lastConnectedAt;
status.lastError = null;
emitStatus();
@@ -1448,33 +1450,39 @@ export async function monitorWebProvider(
// Watchdog: Auto-restart if no messages received for MESSAGE_TIMEOUT_MS
watchdogTimer = setInterval(() => {
if (lastMessageAt) {
const timeSinceLastMessage = Date.now() - lastMessageAt;
if (timeSinceLastMessage > MESSAGE_TIMEOUT_MS) {
const minutesSinceLastMessage = Math.floor(
timeSinceLastMessage / 60000,
);
heartbeatLogger.warn(
{
connectionId,
minutesSinceLastMessage,
lastMessageAt: new Date(lastMessageAt),
messagesHandled: handledMessages,
},
"Message timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
);
void closeListener().catch((err) => {
logVerbose(`Close listener failed: ${formatError(err)}`);
}); // Trigger reconnect
listener.signalClose?.({
status: 499,
isLoggedOut: false,
error: "watchdog-timeout",
});
}
const lastActivityAt = Math.max(
connectionEstablishedAt ?? 0,
lastMessageAt ?? 0,
);
if (!lastActivityAt) return;
const timeSinceLastActivity = Date.now() - lastActivityAt;
if (timeSinceLastActivity > MESSAGE_TIMEOUT_MS) {
const minutesSinceLastActivity = Math.floor(
timeSinceLastActivity / 60000,
);
heartbeatLogger.warn(
{
connectionId,
minutesSinceLastActivity,
connectionEstablishedAt: connectionEstablishedAt
? new Date(connectionEstablishedAt)
: null,
lastMessageAt: lastMessageAt ? new Date(lastMessageAt) : null,
messagesHandled: handledMessages,
},
"Message timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastActivity}m - restarting connection`,
);
void closeListener().catch((err) => {
logVerbose(`Close listener failed: ${formatError(err)}`);
}); // Trigger reconnect
listener.signalClose?.({
status: 499,
isLoggedOut: false,
error: "watchdog-timeout",
});
}
}, WATCHDOG_CHECK_MS);
}
+2 -9
View File
@@ -84,7 +84,6 @@ export type AppViewState = {
chatSending: boolean;
chatMessage: string;
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatThinkingLevel: string | null;
@@ -169,7 +168,6 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleSendChat: () => Promise<void>;
resetToolStream: () => void;
};
export function renderApp(state: AppViewState) {
@@ -243,7 +241,6 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next });
},
onRefresh: () => state.loadOverview(),
@@ -373,24 +370,20 @@ export function renderApp(state: AppViewState) {
state.chatMessage = "";
state.chatStream = null;
state.chatRunId = null;
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next });
void loadChatHistory(state);
},
thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading,
sending: state.chatSending,
messages: [...state.chatMessages, ...state.chatToolMessages],
messages: state.chatMessages,
stream: state.chatStream,
draft: state.chatMessage,
connected: state.connected,
canSend: state.connected && hasConnectedMobileNode,
disabledReason: chatDisabledReason,
sessions: state.sessionsResult,
onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
},
onRefresh: () => loadChatHistory(state),
onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(),
})
-157
View File
@@ -81,62 +81,6 @@ type EventLogEntry = {
payload?: unknown;
};
const TOOL_STREAM_LIMIT = 50;
type AgentEventPayload = {
runId: string;
seq: number;
stream: string;
ts: number;
sessionKey?: string;
data: Record<string, unknown>;
};
type ToolStreamEntry = {
toolCallId: string;
runId: string;
sessionKey?: string;
name: string;
args?: unknown;
output?: string;
startedAt: number;
updatedAt: number;
message: Record<string, unknown>;
};
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.text === "string") return record.text;
const content = record.content;
if (!Array.isArray(content)) return null;
const parts = content
.map((item) => {
if (!item || typeof item !== "object") return null;
const entry = item as Record<string, unknown>;
if (entry.type === "text" && typeof entry.text === "string") return entry.text;
return null;
})
.filter((part): part is string => Boolean(part));
if (parts.length === 0) return null;
return parts.join("\n");
}
function formatToolOutput(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
const contentText = extractToolOutputText(value);
if (contentText) return contentText;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
declare global {
interface Window {
__CLAWDIS_CONTROL_UI_BASE_PATH__?: string;
@@ -181,7 +125,6 @@ export class ClawdisApp extends LitElement {
@state() chatSending = false;
@state() chatMessage = "";
@state() chatMessages: unknown[] = [];
@state() chatToolMessages: unknown[] = [];
@state() chatStream: string | null = null;
@state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null;
@@ -317,8 +260,6 @@ export class ClawdisApp extends LitElement {
private chatScrollFrame: number | null = null;
private chatScrollTimeout: number | null = null;
private nodesPollInterval: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
basePath = "";
private popStateHandler = () => this.onPopState();
private themeMedia: MediaQueryList | null = null;
@@ -351,7 +292,6 @@ export class ClawdisApp extends LitElement {
if (
this.tab === "chat" &&
(changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") ||
changed.has("chatLoading") ||
changed.has("chatMessage") ||
@@ -437,109 +377,12 @@ export class ClawdisApp extends LitElement {
});
}
resetToolStream() {
this.toolStreamById.clear();
this.toolStreamOrder = [];
this.chatToolMessages = [];
}
private trimToolStream() {
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
const removed = this.toolStreamOrder.splice(0, overflow);
for (const id of removed) this.toolStreamById.delete(id);
}
private syncToolStreamMessages() {
this.chatToolMessages = this.toolStreamOrder
.map((id) => this.toolStreamById.get(id)?.message)
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
}
private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
const content: Array<Record<string, unknown>> = [];
content.push({
type: "toolcall",
name: entry.name,
arguments: entry.args ?? {},
});
if (entry.output) {
content.push({
type: "toolresult",
name: entry.name,
text: entry.output,
});
}
return {
role: "assistant",
toolCallId: entry.toolCallId,
runId: entry.runId,
content,
timestamp: entry.startedAt,
};
}
private handleAgentEvent(payload?: AgentEventPayload) {
if (!payload || payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== this.sessionKey) return;
// Fallback: only accept session-less events for the active run.
if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return;
const data = payload.data ?? {};
const toolCallId =
typeof data.toolCallId === "string" ? data.toolCallId : "";
if (!toolCallId) return;
const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined;
const output =
phase === "update"
? formatToolOutput(data.partialResult)
: phase === "result"
? formatToolOutput(data.result)
: undefined;
const now = Date.now();
let entry = this.toolStreamById.get(toolCallId);
if (!entry) {
entry = {
toolCallId,
runId: payload.runId,
sessionKey,
name,
args,
output,
startedAt: typeof payload.ts === "number" ? payload.ts : now,
updatedAt: now,
message: {},
};
this.toolStreamById.set(toolCallId, entry);
this.toolStreamOrder.push(toolCallId);
} else {
entry.name = name;
if (args !== undefined) entry.args = args;
if (output !== undefined) entry.output = output;
entry.updatedAt = now;
}
entry.message = this.buildToolStreamMessage(entry);
this.trimToolStream();
this.syncToolStreamMessages();
}
private onEvent(evt: GatewayEventFrame) {
this.eventLog = [
{ ts: Date.now(), event: evt.event, payload: evt.payload },
...this.eventLog,
].slice(0, 250);
if (evt.event === "agent") {
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
return;
}
if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined;
const state = handleChatEvent(this, payload);
+7 -11
View File
@@ -170,18 +170,14 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const isToolResult = isToolResultMessage(message);
const extractedText = extractText(message);
const contentText = typeof m.content === "string" ? m.content : null;
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
const text = !isToolResult
? extractedText?.trim()
? extractedText
: contentText?.trim()
? contentText
: fallback
: null;
const text =
!isToolResult
? extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2))
: null;
const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";