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

@@ -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)) {