refactor: remove bash pty mode

This commit is contained in:
Peter Steinberger
2026-01-03 20:15:02 +00:00
parent a15cffb7de
commit 16e3535ac0
16 changed files with 94 additions and 364 deletions

View File

@@ -5,6 +5,7 @@
### Breaking ### 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`. - 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`). - 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 ### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
@@ -18,6 +19,7 @@
### Fixes ### Fixes
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - 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. - 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: 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: avoid duplicate block chunks when providers repeat full content on text_end.

View File

@@ -13,10 +13,10 @@ Clawdis runs shell commands through the `bash` tool and keeps longrunning tas
Key parameters: Key parameters:
- `command` (required) - `command` (required)
- `yieldMs` (default 20000): autobackground after this delay - `yieldMs` (default 10000): autobackground after this delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout - `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` - `workdir`, `env`
Behavior: Behavior:
@@ -30,7 +30,7 @@ Environment overrides:
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h) - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h)
Config (preferred): Config (preferred):
- `agent.bash.backgroundMs` (default 20000) - `agent.bash.backgroundMs` (default 10000)
- `agent.bash.timeoutSec` (default 1800) - `agent.bash.timeoutSec` (default 1800)
- `agent.bash.cleanupMs` (default 1800000) - `agent.bash.cleanupMs` (default 1800000)

View File

@@ -12,18 +12,10 @@ Run shell commands in the workspace. Supports foreground + background execution
## Parameters ## Parameters
- `command` (required) - `command` (required)
- `yieldMs` (default 20000): auto-background after delay - `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry - `timeout` (seconds, default 1800): kill on expiry
- `stdinMode` (`pipe` | `pty`): - Need a real TTY? Use the tmux skill.
- `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`).
## Examples ## Examples
@@ -37,8 +29,3 @@ Background + poll:
{"tool":"bash","command":"npm run build","yieldMs":1000} {"tool":"bash","command":"npm run build","yieldMs":1000}
{"tool":"process","action":"poll","sessionId":"<id>"} {"tool":"process","action":"poll","sessionId":"<id>"}
``` ```
TTY command:
```json
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
```

View File

@@ -388,7 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
}, },
maxConcurrent: 3, maxConcurrent: 3,
bash: { bash: {
backgroundMs: 20000, backgroundMs: 10000,
timeoutSec: 1800, timeoutSec: 1800,
cleanupMs: 1800000 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`). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
`agent.bash` configures background bash defaults: `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) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)

View File

@@ -18,10 +18,10 @@ Run shell commands in the workspace.
Core parameters: Core parameters:
- `command` (required) - `command` (required)
- `yieldMs` (auto-background after timeout, default 20000) - `yieldMs` (auto-background after timeout, default 10000)
- `background` (immediate background) - `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800) - `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: Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.

View File

@@ -95,7 +95,6 @@
"grammy": "^1.39.2", "grammy": "^1.39.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"long": "5.3.2", "long": "5.3.2",
"node-pty": "^1.1.0",
"playwright-core": "1.57.0", "playwright-core": "1.57.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",

10
pnpm-lock.yaml generated
View File

@@ -94,9 +94,6 @@ importers:
long: long:
specifier: 5.3.2 specifier: 5.3.2
version: 5.3.2 version: 5.3.2
node-pty:
specifier: ^1.1.0
version: 1.1.0
playwright-core: playwright-core:
specifier: 1.57.0 specifier: 1.57.0
version: 1.57.0 version: 1.57.0
@@ -2188,9 +2185,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'} engines: {node: '>=4.4.0'}
@@ -4834,10 +4828,6 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-wav@0.0.2: node-wav@0.0.2:
optional: true optional: true

View File

@@ -20,7 +20,6 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 10, maxOutputChars: 10,
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 100, maxOutputChars: 100,

View File

@@ -1,5 +1,4 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { IPty } from "node-pty";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute 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 ProcessStatus = "running" | "completed" | "failed" | "killed";
export type ProcessStdinMode = "pipe" | "pty";
export interface ProcessSession { export interface ProcessSession {
id: string; id: string;
command: string; command: string;
child?: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
pty?: IPty;
pid?: number; pid?: number;
stdinMode: ProcessStdinMode;
startedAt: number; startedAt: number;
cwd?: string; cwd?: string;
maxOutputChars: number; maxOutputChars: number;

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");
});
});

View File

@@ -2,10 +2,8 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
import { import {
addSession, addSession,
@@ -18,7 +16,6 @@ import {
listRunningSessions, listRunningSessions,
markBackgrounded, markBackgrounded,
markExited, markExited,
type ProcessStdinMode,
setJobTtlMs, setJobTtlMs,
} from "./bash-process-registry.js"; } from "./bash-process-registry.js";
import { import {
@@ -34,23 +31,6 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000, 1_000,
150_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 = ( const stringEnum = (
values: readonly string[], values: readonly string[],
@@ -81,7 +61,7 @@ const bashSchema = Type.Object({
env: Type.Optional(Type.Record(Type.String(), Type.String())), env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional( yieldMs: Type.Optional(
Type.Number({ Type.Number({
description: "Milliseconds to wait before backgrounding (default 20000)", description: "Milliseconds to wait before backgrounding (default 10000)",
}), }),
), ),
background: Type.Optional( background: Type.Optional(
@@ -92,11 +72,6 @@ const bashSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)", 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 = export type BashToolDetails =
@@ -120,7 +95,7 @@ export function createBashTool(
): AgentTool<any, BashToolDetails> { ): AgentTool<any, BashToolDetails> {
const defaultBackgroundMs = clampNumber( const defaultBackgroundMs = clampNumber(
defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"), defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
20_000, 10_000,
10, 10,
120_000, 120_000,
); );
@@ -133,7 +108,7 @@ export function createBashTool(
name: "bash", name: "bash",
label: "bash", label: "bash",
description: 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, parameters: bashSchema,
execute: async (_toolCallId, args, signal, onUpdate) => { execute: async (_toolCallId, args, signal, onUpdate) => {
const params = args as { const params = args as {
@@ -143,7 +118,6 @@ export function createBashTool(
yieldMs?: number; yieldMs?: number;
background?: boolean; background?: boolean;
timeout?: number; timeout?: number;
stdinMode?: "pipe" | "pty";
}; };
if (!params.command) { if (!params.command) {
@@ -169,84 +143,18 @@ export function createBashTool(
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env; const env = params.env ? { ...process.env, ...params.env } : process.env;
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe"; const child = spawn(shell, [...shellArgs, params.command], {
let stdinMode: ProcessStdinMode = requestedStdinMode; cwd: workdir,
let warning: string | null = null; env,
let child: ChildProcessWithoutNullStreams | undefined; detached: true,
let pty: IPty | undefined; stdio: ["pipe", "pipe", "pipe"],
});
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 session = { const session = {
id: sessionId, id: sessionId,
command: params.command, command: params.command,
child, child,
pty, pid: child?.pid,
pid: child?.pid ?? pty?.pid,
stdinMode,
startedAt, startedAt,
cwd: workdir, cwd: workdir,
maxOutputChars: maxOutput, maxOutputChars: maxOutput,
@@ -309,33 +217,21 @@ export function createBashTool(
}); });
}; };
if (child) { child.stdout.on("data", (data) => {
child.stdout.on("data", (data) => { const str = sanitizeBinaryOutput(data.toString());
const str = sanitizeBinaryOutput(data.toString()); for (const chunk of chunkString(str)) {
for (const chunk of chunkString(str)) { appendOutput(session, "stdout", chunk);
appendOutput(session, "stdout", chunk); emitUpdate();
emitUpdate(); }
} });
});
child.stderr.on("data", (data) => { child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString()); const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) { for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk); appendOutput(session, "stderr", chunk);
emitUpdate(); emitUpdate();
} }
}); });
}
if (pty) {
pty.onData((data) => {
const str = sanitizeBinaryOutput(data);
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
}
return new Promise<AgentToolResult<BashToolDetails>>( return new Promise<AgentToolResult<BashToolDetails>>(
(resolve, reject) => { (resolve, reject) => {
@@ -434,24 +330,16 @@ export function createBashTool(
); );
}; };
if (child) { child.once("exit", (code, exitSignal) => {
child.once("exit", (code, exitSignal) => { handleExit(code, exitSignal);
handleExit(code, exitSignal); });
});
child.once("error", (err) => { child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer); if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed"); markExited(session, null, null, "failed");
settle(() => reject(err)); settle(() => reject(err));
}); });
}
if (pty) {
pty.onExit(({ exitCode, signal }) => {
handleExit(exitCode ?? null, signal ?? null);
});
}
}, },
); );
}, },
@@ -747,43 +635,25 @@ export function createProcessTool(
details: { status: "failed" }, details: { status: "failed" },
}; };
} }
if (session.stdinMode === "pty") { if (!session.child?.stdin || session.child.stdin.destroyed) {
if (!session.pty || session.exited) { return {
return { content: [
content: [ {
{ type: "text",
type: "text", text: `Session ${params.sessionId} stdin is not writable.`,
text: `Session ${params.sessionId} stdin is not writable.`, },
}, ],
], details: { status: "failed" },
details: { status: "failed" }, };
}; }
} await new Promise<void>((resolve, reject) => {
session.pty.write(params.data ?? ""); session.child?.stdin.write(params.data ?? "", (err) => {
if (params.eof) { if (err) reject(err);
session.pty.write("\x04"); else resolve();
}
} 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 (params.eof) { });
session.child.stdin.end(); if (params.eof) {
} session.child.stdin.end();
} }
return { return {
content: [ content: [
@@ -908,21 +778,12 @@ export const processTool = createProcessTool();
function killSession(session: { function killSession(session: {
pid?: number; pid?: number;
stdinMode: ProcessStdinMode;
pty?: IPty;
child?: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
}) { }) {
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid; const pid = session.pid ?? session.child?.pid;
if (pid) { if (pid) {
killProcessTree(pid); killProcessTree(pid);
} }
if (session.stdinMode === "pty") {
try {
session.pty?.kill();
} catch {
// ignore kill failures
}
}
} }
function resolveWorkdir(workdir: string, warnings: string[]) { 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( function clampNumber(
value: number | undefined, value: number | undefined,
defaultValue: number, defaultValue: number,

View File

@@ -494,7 +494,7 @@ export async function runEmbeddedPiAgent(params: {
}; };
const queueHandle: EmbeddedPiQueueHandle = { const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => { queueMessage: async (text: string) => {
await session.queueMessage(text); await session.steer(text);
}, },
isStreaming: () => session.isStreaming, isStreaming: () => session.isStreaming,
abort: abortRun, abort: abortRun,

View File

@@ -367,8 +367,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
message.content?.trim() ?? message.content?.trim() ??
media?.placeholder ?? media?.placeholder ??
message.embeds[0]?.description ?? message.embeds[0]?.description ??
(forwardedSnapshot ? "<forwarded message>" : "") ?? (forwardedSnapshot ? "<forwarded message>" : "");
"";
if (!text) { if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`); logVerbose(`discord: drop message ${message.id} (empty content)`);
return; return;

View File

@@ -1,5 +1,4 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
@@ -64,6 +63,9 @@ describe("gateway server providers", () => {
test("telegram.logout clears bot token from config", async () => { test("telegram.logout clears bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN; const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN;
const { readConfigFileSnapshot, writeConfigFile } = await import(
"../config/config.js"
);
await writeConfigFile({ await writeConfigFile({
telegram: { telegram: {
botToken: "123:abc", botToken: "123:abc",

View File

@@ -86,6 +86,11 @@ import {
authorizeGatewayConnect, authorizeGatewayConnect,
type ResolvedGatewayAuth, type ResolvedGatewayAuth,
} from "./auth.js"; } from "./auth.js";
import {
type GatewayReloadPlan,
type ProviderKind,
startGatewayConfigReloader,
} from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js"; import { normalizeControlUiBasePath } from "./control-ui.js";
import { resolveHooksConfig } from "./hooks.js"; import { resolveHooksConfig } from "./hooks.js";
import { import {
@@ -94,13 +99,12 @@ import {
resolveGatewayBindHost, resolveGatewayBindHost,
} from "./net.js"; } from "./net.js";
import { createBridgeHandlers } from "./server-bridge.js"; import { createBridgeHandlers } from "./server-bridge.js";
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { import {
startGatewayConfigReloader, type BridgeListConnectedFn,
type GatewayReloadPlan, type BridgeSendEventFn,
type ProviderKind, createBridgeSubscriptionManager,
} from "./config-reload.js"; } from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js"; import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import { import {
DEDUPE_MAX, DEDUPE_MAX,
@@ -862,6 +866,11 @@ export async function startGatewayServer(
const bridgeSubscribe = bridgeSubscriptions.subscribe; const bridgeSubscribe = bridgeSubscriptions.subscribe;
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe; const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll; const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
bridge?.sendEvent(opts);
};
const bridgeListConnected: BridgeListConnectedFn = () =>
bridge?.listConnected() ?? [];
const bridgeSendToSession = ( const bridgeSendToSession = (
sessionKey: string, sessionKey: string,
event: string, event: string,
@@ -871,20 +880,16 @@ export async function startGatewayServer(
sessionKey, sessionKey,
event, event,
payload, payload,
bridge ? (opts) => bridge.sendEvent(opts) : undefined, bridgeSendEvent,
); );
const bridgeSendToAllSubscribed = (event: string, payload: unknown) => const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllSubscribed( bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
event,
payload,
bridge ? (opts) => bridge.sendEvent(opts) : undefined,
);
const bridgeSendToAllConnected = (event: string, payload: unknown) => const bridgeSendToAllConnected = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllConnected( bridgeSubscriptions.sendToAllConnected(
event, event,
payload, payload,
bridge ? () => bridge.listConnected() : undefined, bridgeListConnected,
bridge ? (opts) => bridge.sendEvent(opts) : undefined, bridgeSendEvent,
); );
const broadcastVoiceWakeChanged = (triggers: string[]) => { const broadcastVoiceWakeChanged = (triggers: string[]) => {
@@ -1663,7 +1668,9 @@ export async function startGatewayServer(
if (plan.restartProviders.size > 0) { if (plan.restartProviders.size > 0) {
if (process.env.CLAWDIS_SKIP_PROVIDERS === "1") { 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 { } else {
const restartProvider = async ( const restartProvider = async (
name: ProviderKind, name: ProviderKind,
@@ -1712,10 +1719,7 @@ export async function startGatewayServer(
} }
} }
setCommandLaneConcurrency( setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
"cron",
nextConfig.cron?.maxConcurrentRuns ?? 1,
);
setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1); setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1);
if (plan.hotReasons.length > 0) { if (plan.hotReasons.length > 0) {

View File

@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest"; import { afterEach, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { agentCommand } from "../commands/agent.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@@ -64,6 +63,7 @@ const hoisted = vi.hoisted(() => ({
}>, }>,
}, },
cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })), cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })),
agentCommand: vi.fn().mockResolvedValue(undefined),
testIsNixMode: { value: false }, testIsNixMode: { value: false },
sessionStoreSaveDelayMs: { value: 0 }, sessionStoreSaveDelayMs: { value: 0 },
})); }));
@@ -75,6 +75,7 @@ export const bridgeSendEvent = hoisted.bridgeSendEvent;
export const testTailnetIPv4 = hoisted.testTailnetIPv4; export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock; export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun; export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand = hoisted.agentCommand;
export const testState = { export const testState = {
sessionStorePath: undefined as string | undefined, sessionStorePath: undefined as string | undefined,
@@ -290,7 +291,7 @@ vi.mock("../web/outbound.js", () => ({
.mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), .mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
})); }));
vi.mock("../commands/agent.js", () => ({ vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn().mockResolvedValue(undefined), agentCommand,
})); }));
process.env.CLAWDIS_SKIP_PROVIDERS = "1"; process.env.CLAWDIS_SKIP_PROVIDERS = "1";
@@ -509,5 +510,3 @@ export async function waitForSystemEvent(timeoutMs = 2000) {
} }
throw new Error("timeout waiting for system event"); throw new Error("timeout waiting for system event");
} }
export { agentCommand };