Merge remote-tracking branch 'origin/main' into upstream-preview-nix-2025-12-20

This commit is contained in:
Peter Steinberger
2026-01-01 09:15:28 +01:00
163 changed files with 10867 additions and 1712 deletions

View File

@@ -506,7 +506,7 @@ function createBrowserTool(): AnyAgentTool {
label: "Clawdis Browser",
name: "clawdis_browser",
description:
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation.",
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;

View File

@@ -0,0 +1,49 @@
import { completeSimple, type Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
describeLive("minimax live", () => {
it(
"returns assistant text",
async () => {
const model: Model<"openai-completions"> = {
id: MINIMAX_MODEL,
name: `MiniMax ${MINIMAX_MODEL}`,
api: "openai-completions",
provider: "minimax",
baseUrl: MINIMAX_BASE_URL,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
};
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{ apiKey: MINIMAX_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
},
20000,
);
});

View File

@@ -0,0 +1,35 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages } from "./tool-images.js";
describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => {
const width = 2800;
const height = 2800;
const raw = Buffer.alloc(width * height * 3, 0xff);
const bigPng = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 0 })
.toBuffer();
expect(bigPng.byteLength).toBeGreaterThan(5 * 1024 * 1024);
const blocks = [
{
type: "image" as const,
data: bigPng.toString("base64"),
mimeType: "image/png",
},
];
const out = await sanitizeContentBlocksImages(blocks, "test");
const image = out.find((b) => b.type === "image");
if (!image || image.type !== "image") {
throw new Error("expected image block");
}
const size = Buffer.from(image.data, "base64").byteLength;
expect(size).toBeLessThanOrEqual(5 * 1024 * 1024);
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
});

View File

@@ -1,19 +1,19 @@
import type { AgentToolResult } from "@mariozechner/pi-ai";
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
import { detectMime } from "../media/mime.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// Anthropic Messages API limitation (observed in Clawdis sessions):
// When sending many images in a single request (e.g. via session history + tool results),
// Anthropic rejects any image where *either* dimension exceeds 2000px.
// Anthropic Messages API limitations (observed in Clawdis sessions):
// - Images over ~2000px per side can fail in multi-image requests.
// - Images over 5MB are rejected by the API.
//
// To keep sessions resilient (and avoid "silent" WhatsApp non-replies), we auto-downscale
// all base64 image blocks above this limit while preserving aspect ratio.
// and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false;
@@ -35,66 +35,80 @@ async function resizeImageBase64IfNeeded(params: {
base64: string;
mimeType: string;
maxDimensionPx: number;
maxBytes: number;
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf);
const width = meta?.width;
const height = meta?.height;
if (
typeof width !== "number" ||
typeof height !== "number" ||
(width <= params.maxDimensionPx && height <= params.maxDimensionPx)
const overBytes = buf.byteLength > params.maxBytes;
const maxDim = Math.max(width ?? 0, height ?? 0);
if (typeof width !== "number" || typeof height !== "number") {
if (!overBytes) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
};
}
} else if (
!overBytes &&
width <= params.maxDimensionPx &&
height <= params.maxDimensionPx
) {
return { base64: params.base64, mimeType: params.mimeType, resized: false };
}
const mime = params.mimeType.toLowerCase();
let out: Buffer;
try {
const mod = (await import("sharp")) as unknown as {
default?: typeof import("sharp");
};
const sharp = mod.default ?? (mod as unknown as typeof import("sharp"));
const img = sharp(buf, { failOnError: false }).resize({
width: params.maxDimensionPx,
height: params.maxDimensionPx,
fit: "inside",
withoutEnlargement: true,
});
if (mime === "image/jpeg" || mime === "image/jpg") {
out = await img.jpeg({ quality: 85 }).toBuffer();
} else if (mime === "image/webp") {
out = await img.webp({ quality: 85 }).toBuffer();
} else if (mime === "image/png") {
out = await img.png().toBuffer();
} else {
out = await img.png().toBuffer();
const qualities = [85, 75, 65, 55, 45, 35];
const sideStart =
maxDim > 0
? Math.min(params.maxDimensionPx, maxDim)
: params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v))
.filter((v, i, arr) => v > 0 && arr.indexOf(v) === i)
.sort((a, b) => b - a);
let smallest: { buffer: Buffer; size: number } | null = null;
for (const side of sideGrid) {
for (const quality of qualities) {
const out = await resizeToJpeg({
buffer: buf,
maxSide: side,
quality,
withoutEnlargement: true,
});
if (!smallest || out.byteLength < smallest.size) {
smallest = { buffer: out, size: out.byteLength };
}
if (out.byteLength <= params.maxBytes) {
return {
base64: out.toString("base64"),
mimeType: "image/jpeg",
resized: true,
};
}
}
} catch {
// Bun can't load sharp native addons. Fall back to a JPEG conversion.
out = await resizeToJpeg({
buffer: buf,
maxSide: params.maxDimensionPx,
quality: 85,
withoutEnlargement: true,
});
}
const sniffed = await detectMime({ buffer: out.slice(0, 256) });
const nextMime = sniffed?.startsWith("image/") ? sniffed : params.mimeType;
return { base64: out.toString("base64"), mimeType: nextMime, resized: true };
const best = smallest?.buffer ?? buf;
const maxMb = (params.maxBytes / (1024 * 1024)).toFixed(0);
const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2);
throw new Error(
`Image could not be reduced below ${maxMb}MB (got ${gotMb}MB)`,
);
}
export async function sanitizeContentBlocksImages(
blocks: ToolContentBlock[],
label: string,
opts: { maxDimensionPx?: number } = {},
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
): Promise<ToolContentBlock[]> {
const maxDimensionPx = Math.max(
opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX,
1,
);
const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1);
const out: ToolContentBlock[] = [];
for (const block of blocks) {
@@ -117,6 +131,7 @@ export async function sanitizeContentBlocksImages(
base64: data,
mimeType: block.mimeType,
maxDimensionPx,
maxBytes,
});
out.push({ ...block, data: resized.base64, mimeType: resized.mimeType });
} catch (err) {
@@ -133,7 +148,7 @@ export async function sanitizeContentBlocksImages(
export async function sanitizeToolResultImages(
result: AgentToolResult<unknown>,
label: string,
opts: { maxDimensionPx?: number } = {},
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
): Promise<AgentToolResult<unknown>> {
const content = Array.isArray(result.content) ? result.content : [];
if (!content.some((b) => isImageBlock(b) || isTextBlock(b))) return result;

View File

@@ -0,0 +1,35 @@
import { completeSimple, getModel } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
describeLive("zai live", () => {
it(
"returns assistant text",
async () => {
const model = getModel("zai", "glm-4.7");
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{ apiKey: ZAI_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
},
20000,
);
});

View File

@@ -7,8 +7,8 @@ import { normalizeBrowserScreenshot } from "./screenshot.js";
describe("browser screenshot normalization", () => {
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {
const width = 2800;
const height = 2800;
const width = 2300;
const height = 2300;
const raw = crypto.randomBytes(width * height * 3);
const bigPng = await sharp(raw, { raw: { width, height, channels: 3 } })
.png({ compressionLevel: 0 })
@@ -25,7 +25,7 @@ describe("browser screenshot normalization", () => {
expect(Number(meta.height)).toBeLessThanOrEqual(2000);
expect(normalized.buffer[0]).toBe(0xff);
expect(normalized.buffer[1]).toBe(0xd8);
}, 20_000);
}, 30_000);
it("keeps already-small screenshots unchanged", async () => {
const jpeg = await sharp({

View File

@@ -1 +1 @@
8c6030afb0b9f264b0bb9dcfb738b67d361fc5acac7967b4e056169a44f95184
c611d556d551748bf0bdae1d8a3f7d6055c061d92ef24060d89a430bfeeff6f8

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,21 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clawdis Canvas</title>
<script>
(() => {
try {
const params = new URLSearchParams(window.location.search);
const platform = (params.get('platform') || '').trim().toLowerCase();
if (platform) {
document.documentElement.dataset.platform = platform;
return;
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
}
} catch (_) {}
})();
</script>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
@@ -20,6 +35,13 @@
color: #e5e7eb;
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
#0b1328;
}
body::before {
content:"";
position: fixed;
@@ -37,6 +59,7 @@
pointer-events: none;
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
body::after {
content:"";
position: fixed;
@@ -54,6 +77,7 @@
pointer-events: none;
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@@ -79,6 +103,13 @@
touch-action: none;
z-index: 1;
}
:root[data-platform="android"] #clawdis-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
#141c33;
}
#clawdis-status {
position: fixed;
inset: 0;

View File

@@ -354,3 +354,79 @@ describe("Nix integration (U3, U5, U9)", () => {
});
});
});
describe("talk api key fallback", () => {
let previousEnv: string | undefined;
beforeEach(() => {
previousEnv = process.env.ELEVENLABS_API_KEY;
delete process.env.ELEVENLABS_API_KEY;
});
afterEach(() => {
process.env.ELEVENLABS_API_KEY = previousEnv;
});
it("injects talk.apiKey from profile when config is missing", async () => {
await withTempHome(async (home) => {
await fs.writeFile(
path.join(home, ".profile"),
"export ELEVENLABS_API_KEY=profile-key\n",
"utf-8",
);
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.config?.talk?.apiKey).toBe("profile-key");
expect(snap.exists).toBe(false);
});
});
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
await withTempHome(async (home) => {
await fs.writeFile(
path.join(home, ".profile"),
"export ELEVENLABS_API_KEY=profile-key\n",
"utf-8",
);
process.env.ELEVENLABS_API_KEY = "env-key";
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.config?.talk?.apiKey).toBe("env-key");
});
});
});
describe("talk.voiceAliases", () => {
it("accepts a string map of voice aliases", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
talk: {
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
Roger: "CwhRBWXzGAHq8TQ4Fs17",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects non-string voice alias values", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
talk: {
voiceAliases: {
Clawd: 123,
},
},
});
expect(res.ok).toBe(false);
});
});

View File

@@ -231,6 +231,21 @@ export type CanvasHostConfig = {
port?: number;
};
export type TalkConfig = {
/** Default ElevenLabs voice ID for Talk mode. */
voiceId?: string;
/** Optional voice name -> ElevenLabs voice ID map. */
voiceAliases?: Record<string, string>;
/** Default ElevenLabs model ID for Talk mode. */
modelId?: string;
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
outputFormat?: string;
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
apiKey?: string;
/** Stop speaking when user starts talking (default: true). */
interruptOnSpeech?: boolean;
};
export type GatewayControlUiConfig = {
/** If false, the Gateway will not serve the Control UI (/). Default: true. */
enabled?: boolean;
@@ -345,6 +360,10 @@ export type ClawdisConfig = {
};
logging?: LoggingConfig;
browser?: BrowserConfig;
ui?: {
/** Accent color for Clawdis UI chrome (hex). */
seamColor?: string;
};
skillsLoad?: SkillsLoadConfig;
skillsInstall?: SkillsInstallConfig;
models?: ModelsConfig;
@@ -403,6 +422,7 @@ export type ClawdisConfig = {
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
talk?: TalkConfig;
gateway?: GatewayConfig;
skills?: Record<string, SkillConfig>;
};
@@ -502,6 +522,10 @@ const TranscribeAudioSchema = z
})
.optional();
const HexColorSchema = z
.string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -680,6 +704,11 @@ const ClawdisSchema = z.object({
attachOnly: z.boolean().optional(),
})
.optional(),
ui: z
.object({
seamColor: HexColorSchema.optional(),
})
.optional(),
models: ModelsConfigSchema,
agent: z
.object({
@@ -808,6 +837,16 @@ const ClawdisSchema = z.object({
port: z.number().int().positive().optional(),
})
.optional(),
talk: z
.object({
voiceId: z.string().optional(),
voiceAliases: z.record(z.string(), z.string()).optional(),
modelId: z.string().optional(),
outputFormat: z.string().optional(),
apiKey: z.string().optional(),
interruptOnSpeech: z.boolean().optional(),
})
.optional(),
gateway: z
.object({
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
@@ -967,17 +1006,59 @@ export function parseConfigJson5(
}
}
function readTalkApiKeyFromProfile(): string | null {
const home = os.homedir();
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map(
(name) => path.join(home, name),
);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
try {
const text = fs.readFileSync(candidate, "utf-8");
const match = text.match(
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
);
const value = match?.[1]?.trim();
if (value) return value;
} catch {
// Ignore profile read errors.
}
}
return null;
}
function resolveTalkApiKey(): string | null {
const envValue = (process.env.ELEVENLABS_API_KEY ?? "").trim();
if (envValue) return envValue;
return readTalkApiKeyFromProfile();
}
function applyTalkApiKey(config: ClawdisConfig): ClawdisConfig {
const resolved = resolveTalkApiKey();
if (!resolved) return config;
const existing = config.talk?.apiKey?.trim();
if (existing) return config;
return {
...config,
talk: {
...config.talk,
apiKey: resolved,
},
};
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const configPath = CONFIG_PATH_CLAWDIS;
const exists = fs.existsSync(configPath);
if (!exists) {
const config = applyTalkApiKey({});
return {
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: {},
config,
issues: [],
};
}
@@ -1018,7 +1099,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
raw,
parsed: parsedRes.parsed,
valid: true,
config: validated.config,
config: applyTalkApiKey(validated.config),
issues: [],
};
} catch (err) {

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("ui.seamColor", () => {
it("accepts hex colors", () => {
const res = validateConfigObject({ ui: { seamColor: "#FF4500" } });
expect(res.ok).toBe(true);
});
it("rejects non-hex colors", () => {
const res = validateConfigObject({ ui: { seamColor: "lobster" } });
expect(res.ok).toBe(false);
});
it("rejects invalid hex length", () => {
const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } });
expect(res.ok).toBe(false);
});
});

View File

@@ -25,6 +25,7 @@ export async function callGateway<T = unknown>(
const timeoutMs = opts.timeoutMs ?? 10_000;
return await new Promise<T>((resolve, reject) => {
let settled = false;
let ignoreClose = false;
const stop = (err?: Error, value?: T) => {
if (settled) return;
settled = true;
@@ -49,19 +50,23 @@ export async function callGateway<T = unknown>(
const result = await client.request<T>(opts.method, opts.params, {
expectFinal: opts.expectFinal,
});
client.stop();
ignoreClose = true;
stop(undefined, result);
client.stop();
} catch (err) {
ignoreClose = true;
client.stop();
stop(err as Error);
}
},
onClose: (code, reason) => {
if (settled || ignoreClose) return;
stop(new Error(`gateway closed (${code}): ${reason}`));
},
});
const timer = setTimeout(() => {
ignoreClose = true;
client.stop();
stop(new Error("gateway timeout"));
}, timeoutMs);

View File

@@ -8,14 +8,23 @@ const ROOT_PREFIX = "/";
function resolveControlUiRoot(): string | null {
const here = path.dirname(fileURLToPath(import.meta.url));
const execDir = (() => {
try {
return path.dirname(fs.realpathSync(process.execPath));
} catch {
return null;
}
})();
const candidates = [
// Packaged relay: Resources/Relay/control-ui
execDir ? path.resolve(execDir, "control-ui") : null,
// Running from dist: dist/gateway/control-ui.js -> dist/control-ui
path.resolve(here, "../control-ui"),
// Running from source: src/gateway/control-ui.ts -> dist/control-ui
path.resolve(here, "../../dist/control-ui"),
// Fallback to cwd (dev)
path.resolve(process.cwd(), "dist", "control-ui"),
];
].filter((dir): dir is string => Boolean(dir));
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
}

View File

@@ -95,6 +95,8 @@ import {
SnapshotSchema,
type StateVersion,
StateVersionSchema,
type TalkModeParams,
TalkModeParamsSchema,
type TickEvent,
TickEventSchema,
type WakeParams,
@@ -169,6 +171,8 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateTalkModeParams =
ajv.compile<TalkModeParams>(TalkModeParamsSchema);
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
ProvidersStatusParamsSchema,
);
@@ -297,6 +301,7 @@ export type {
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
TalkModeParams,
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,

View File

@@ -339,6 +339,14 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const TalkModeParamsSchema = Type.Object(
{
enabled: Type.Boolean(),
phase: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const ProvidersStatusParamsSchema = Type.Object(
{
probe: Type.Optional(Type.Boolean()),
@@ -668,6 +676,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsCompactParams: SessionsCompactParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
TalkModeParams: TalkModeParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
@@ -724,6 +733,7 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;

View File

@@ -444,58 +444,66 @@ async function waitForSystemEvent(timeoutMs = 2000) {
}
describe("gateway server", () => {
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 15_000 },
async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(initial.ok).toBe(true);
expect(initial.payload?.triggers).toEqual(["clawd", "claude", "computer"]);
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(initial.ok).toBe(true);
expect(initial.payload?.triggers).toEqual([
"clawd",
"claude",
"computer",
]);
const changedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
const changedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: [" hi ", "", "there"],
});
expect(setRes.ok).toBe(true);
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: [" hi ", "", "there"],
});
expect(setRes.ok).toBe(true);
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
const changed = await changedP;
expect(changed.event).toBe("voicewake.changed");
expect(
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
).toEqual(["hi", "there"]);
const changed = await changedP;
expect(changed.event).toBe("voicewake.changed");
expect(
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
).toEqual(["hi", "there"]);
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(after.ok).toBe(true);
expect(after.payload?.triggers).toEqual(["hi", "there"]);
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(after.ok).toBe(true);
expect(after.payload?.triggers).toEqual(["hi", "there"]);
const onDisk = JSON.parse(
await fs.readFile(
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
"utf8",
),
) as { triggers?: unknown; updatedAtMs?: unknown };
expect(onDisk.triggers).toEqual(["hi", "there"]);
expect(typeof onDisk.updatedAtMs).toBe("number");
const onDisk = JSON.parse(
await fs.readFile(
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
"utf8",
),
) as { triggers?: unknown; updatedAtMs?: unknown };
expect(onDisk.triggers).toEqual(["hi", "there"]);
expect(typeof onDisk.updatedAtMs).toBe("number");
ws.close();
await server.close();
ws.close();
await server.close();
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
},
);
test("models.list returns model catalog", async () => {
piSdkMock.enabled = true;
@@ -3339,6 +3347,90 @@ describe("gateway server", () => {
await server.close();
});
test("bridge voice transcript triggers chat events for webchat clients", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
name: "webchat",
version: "1.0.0",
platform: "test",
mode: "webchat",
},
});
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
const isVoiceFinalChatEvent = (o: unknown) => {
if (!o || typeof o !== "object") return false;
const rec = o as Record<string, unknown>;
if (rec.type !== "event" || rec.event !== "chat") return false;
if (!rec.payload || typeof rec.payload !== "object") return false;
const payload = rec.payload as Record<string, unknown>;
const runId = typeof payload.runId === "string" ? payload.runId : "";
const state = typeof payload.state === "string" ? payload.state : "";
return runId.startsWith("voice-") && state === "final";
};
const finalChatP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, isVoiceFinalChatEvent, 8000);
await bridgeCall?.onEvent?.("ios-node", {
event: "voice.transcript",
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
});
emitAgentEvent({
runId: "sess-main",
seq: 1,
ts: Date.now(),
stream: "assistant",
data: { text: "hi from agent" },
});
emitAgentEvent({
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "job",
data: { state: "done" },
});
const evt = await finalChatP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
const message =
payload.message && typeof payload.message === "object"
? (payload.message as Record<string, unknown>)
: {};
expect(message.role).toBe("assistant");
ws.close();
await server.close();
});
test("bridge chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");

View File

@@ -421,6 +421,7 @@ import {
validateSkillsInstallParams,
validateSkillsStatusParams,
validateSkillsUpdateParams,
validateTalkModeParams,
validateWakeParams,
validateWebLoginStartParams,
validateWebLoginWaitParams,
@@ -497,6 +498,7 @@ const METHODS = [
"status",
"config.get",
"config.set",
"talk.mode",
"models.list",
"skills.status",
"skills.install",
@@ -546,6 +548,7 @@ const EVENTS = [
"chat",
"presence",
"tick",
"talk.mode",
"shutdown",
"health",
"heartbeat",
@@ -1673,6 +1676,19 @@ export async function startGatewayServer(
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
const bridgeSessionSubscribers = new Map<string, Set<string>>();
const isMobilePlatform = (platform: unknown): boolean => {
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
if (!p) return false;
return (
p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android")
);
};
const hasConnectedMobileNode = (): boolean => {
const connected = bridge?.listConnected?.() ?? [];
return connected.some((n) => isMobilePlatform(n.platform));
};
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
@@ -2406,6 +2422,25 @@ export async function startGatewayServer(
}),
};
}
case "talk.mode": {
const params = parseParams();
if (!validateTalkModeParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
},
};
}
const payload = {
enabled: (params as { enabled: boolean }).enabled,
phase: (params as { phase?: string }).phase ?? null,
ts: Date.now(),
};
broadcast("talk.mode", payload, { dropIfSlow: true });
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "models.list": {
const params = parseParams();
if (!validateModelsListParams(params)) {
@@ -3069,6 +3104,13 @@ export async function startGatewayServer(
await saveSessionStore(storePath, store);
}
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
chatRunSessions.set(sessionId, {
sessionKey,
clientRunId: `voice-${randomUUID()}`,
});
void agentCommand(
{
message: text,
@@ -4092,6 +4134,21 @@ export async function startGatewayServer(
break;
}
case "chat.send": {
if (
client &&
isWebchatConnect(client.connect) &&
!hasConnectedMobileNode()
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"web chat disabled: no connected iOS/Android nodes",
),
);
break;
}
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateChatSendParams(params)) {
respond(
@@ -4642,6 +4699,43 @@ export async function startGatewayServer(
);
break;
}
case "talk.mode": {
if (
client &&
isWebchatConnect(client.connect) &&
!hasConnectedMobileNode()
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"talk disabled: no connected iOS/Android nodes",
),
);
break;
}
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateTalkModeParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
),
);
break;
}
const payload = {
enabled: (params as { enabled: boolean }).enabled,
phase: (params as { phase?: string }).phase ?? null,
ts: Date.now(),
};
broadcast("talk.mode", payload, { dropIfSlow: true });
respond(true, payload, undefined);
break;
}
case "skills.status": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSkillsStatusParams(params)) {

View File

@@ -17,6 +17,7 @@ import {
saveSessionStore,
} from "./config/sessions.js";
import { ensureBinary } from "./infra/binaries.js";
import { normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureClawdisCliOnPath } from "./infra/path-env.js";
import {
@@ -32,6 +33,7 @@ import { monitorWebProvider } from "./provider-web.js";
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
dotenv.config({ quiet: true });
normalizeEnv();
ensureClawdisCliOnPath();
// Capture all console output into structured logs while keeping stdout/stderr behavior.

37
src/infra/env.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { normalizeZaiEnv } from "./env.js";
describe("normalizeZaiEnv", () => {
it("copies Z_AI_API_KEY to ZAI_API_KEY when missing", () => {
const prevZai = process.env.ZAI_API_KEY;
const prevZAi = process.env.Z_AI_API_KEY;
process.env.ZAI_API_KEY = "";
process.env.Z_AI_API_KEY = "zai-legacy";
normalizeZaiEnv();
expect(process.env.ZAI_API_KEY).toBe("zai-legacy");
if (prevZai === undefined) delete process.env.ZAI_API_KEY;
else process.env.ZAI_API_KEY = prevZai;
if (prevZAi === undefined) delete process.env.Z_AI_API_KEY;
else process.env.Z_AI_API_KEY = prevZAi;
});
it("does not override existing ZAI_API_KEY", () => {
const prevZai = process.env.ZAI_API_KEY;
const prevZAi = process.env.Z_AI_API_KEY;
process.env.ZAI_API_KEY = "zai-current";
process.env.Z_AI_API_KEY = "zai-legacy";
normalizeZaiEnv();
expect(process.env.ZAI_API_KEY).toBe("zai-current");
if (prevZai === undefined) delete process.env.ZAI_API_KEY;
else process.env.ZAI_API_KEY = prevZai;
if (prevZAi === undefined) delete process.env.Z_AI_API_KEY;
else process.env.Z_AI_API_KEY = prevZAi;
});
});

9
src/infra/env.ts Normal file
View File

@@ -0,0 +1,9 @@
export function normalizeZaiEnv(): void {
if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) {
process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY;
}
}
export function normalizeEnv(): void {
normalizeZaiEnv();
}

View File

@@ -392,7 +392,7 @@ describe("web auto-reply", () => {
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
});
}, 15_000);
it(
"stops after hitting max reconnect attempts",