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
33 changed files with 632 additions and 558 deletions

No files matched your search

+5
View File
@@ -5,6 +5,7 @@
### 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.
@@ -17,16 +18,20 @@
- 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.
- 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.
+6
View File
@@ -14,6 +14,10 @@ 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:
@@ -85,6 +89,8 @@ 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)
+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}
```
+2 -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)
+1
View File
@@ -20,6 +20,7 @@ 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`)
+5
View File
@@ -75,6 +75,11 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
**🎭 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
@@ -13,6 +13,7 @@ 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`)
-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
-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,
+1 -3
View File
@@ -486,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,
@@ -497,7 +497,6 @@ export async function runEmbeddedPiAgent(params: {
assistantTexts,
toolMetas,
unsubscribe,
flush: flushToolDebouncer,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
@@ -571,7 +570,6 @@ 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,4 +630,107 @@ 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);
});
});
+32 -37
View File
@@ -2,10 +2,7 @@ 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 {
createToolDebouncer,
formatToolAggregate,
} from "../auto-reply/tool-meta.js";
import { 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";
@@ -113,6 +110,7 @@ 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 = "";
@@ -176,17 +174,25 @@ 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;
@@ -298,12 +304,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(
@@ -336,6 +342,15 @@ 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") {
@@ -382,7 +397,8 @@ export function subscribeEmbeddedPiSession(params: {
const sanitizedResult = sanitizeToolResult(result);
const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta });
toolDebouncer.push(toolName, meta);
toolMetaById.delete(toolCallId);
toolSummaryById.delete(toolCallId);
emitAgentEvent({
runId: params.runId,
@@ -406,25 +422,6 @@ 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") {
@@ -626,7 +623,6 @@ 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 {
@@ -640,7 +636,6 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts,
toolMetas,
unsubscribe,
flush: () => toolDebouncer.flush(),
waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise();
-35
View File
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createToolDebouncer,
formatToolAggregate,
formatToolPrefix,
shortenMeta,
@@ -48,37 +47,3 @@ 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,9 +4,6 @@ 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);
}
@@ -77,33 +74,3 @@ 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 };
}
+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: {
+71 -3
View File
@@ -10,6 +10,7 @@ import {
type Guild,
type Message,
type MessageReaction,
type MessageSnapshot,
MessageType,
type PartialMessage,
type PartialMessageReaction,
@@ -208,7 +209,11 @@ 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"}`,
@@ -362,7 +367,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
message.content?.trim() ??
media?.placeholder ??
message.embeds[0]?.description ??
"";
(forwardedSnapshot ? "<forwarded message>" : "");
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
@@ -409,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,
@@ -848,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 {
@@ -952,6 +1001,25 @@ function resolveDiscordSystemEvent(message: Message): string | 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;
+8 -5
View File
@@ -186,12 +186,18 @@ 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,
@@ -200,18 +206,15 @@ export function createAgentEventHandler({
});
}
agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", evt);
broadcast("agent", agentPayload);
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", evt);
bridgeSendToSession(sessionKey, "agent", agentPayload);
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const clientRunId = chatLink?.clientRunId ?? evt.runId;
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
+39
View File
@@ -478,4 +478,43 @@ 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();
});
});
+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",
+23 -19
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,13 +99,12 @@ import {
resolveGatewayBindHost,
} from "./net.js";
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";
type BridgeListConnectedFn,
type BridgeSendEventFn,
createBridgeSubscriptionManager,
} from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import {
DEDUPE_MAX,
@@ -862,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,
@@ -871,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[]) => {
@@ -1663,7 +1668,9 @@ export async function startGatewayServer(
if (plan.restartProviders.size > 0) {
if (process.env.CLAWDIS_SKIP_PROVIDERS === "1") {
logProviders.info("skipping provider reload (CLAWDIS_SKIP_PROVIDERS=1)");
logProviders.info(
"skipping provider reload (CLAWDIS_SKIP_PROVIDERS=1)",
);
} else {
const restartProvider = async (
name: ProviderKind,
@@ -1712,10 +1719,7 @@ export async function startGatewayServer(
}
}
setCommandLaneConcurrency(
"cron",
nextConfig.cron?.maxConcurrentRuns ?? 1,
);
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1);
if (plan.hotReasons.length > 0) {
+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 };
+1
View File
@@ -11,6 +11,7 @@ export type AgentEventPayload = {
stream: AgentEventStream;
ts: number;
data: Record<string, unknown>;
sessionKey?: string;
};
export type AgentRunContext = {
+28 -36
View File
@@ -921,7 +921,6 @@ 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;
@@ -1389,8 +1388,7 @@ export async function monitorWebProvider(
});
status.connected = true;
connectionEstablishedAt = Date.now();
status.lastConnectedAt = connectionEstablishedAt;
status.lastConnectedAt = Date.now();
status.lastEventAt = status.lastConnectedAt;
status.lastError = null;
emitStatus();
@@ -1450,39 +1448,33 @@ export async function monitorWebProvider(
// Watchdog: Auto-restart if no messages received for MESSAGE_TIMEOUT_MS
watchdogTimer = setInterval(() => {
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",
});
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",
});
}
}
}, WATCHDOG_CHECK_MS);
}
+9 -2
View File
@@ -84,6 +84,7 @@ export type AppViewState = {
chatSending: boolean;
chatMessage: string;
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatThinkingLevel: string | null;
@@ -168,6 +169,7 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleSendChat: () => Promise<void>;
resetToolStream: () => void;
};
export function renderApp(state: AppViewState) {
@@ -241,6 +243,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next });
},
onRefresh: () => state.loadOverview(),
@@ -370,20 +373,24 @@ 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,
messages: [...state.chatMessages, ...state.chatToolMessages],
stream: state.chatStream,
draft: state.chatMessage,
connected: state.connected,
canSend: state.connected && hasConnectedMobileNode,
disabledReason: chatDisabledReason,
sessions: state.sessionsResult,
onRefresh: () => loadChatHistory(state),
onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
},
onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(),
})
+157
View File
@@ -81,6 +81,62 @@ 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;
@@ -125,6 +181,7 @@ 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;
@@ -260,6 +317,8 @@ 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;
@@ -292,6 +351,7 @@ export class ClawdisApp extends LitElement {
if (
this.tab === "chat" &&
(changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") ||
changed.has("chatLoading") ||
changed.has("chatMessage") ||
@@ -377,12 +437,109 @@ 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);
+11 -7
View File
@@ -170,14 +170,18 @@ 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 text =
!isToolResult
? extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2))
: null;
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 timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";